From 0e813604ca76916b5da9d7663b9bf107088aaec5 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 26 Jun 2024 16:21:29 -0700 Subject: [PATCH 001/258] Adds RedwoodJob, adapters, Worker and Executor classes, along with bins --- packages/jobs/.babelrc.js | 1 + packages/jobs/README.md | 3 + packages/jobs/build.mts | 3 + packages/jobs/package.json | 39 ++ packages/jobs/src/adapters/BaseAdapter.js | 39 ++ packages/jobs/src/adapters/PrismaAdapter.js | 182 ++++++ .../adapters/__tests__/BaseAdapter.test.js | 49 ++ .../__tests__/PrismaAdapter.scenarios.js | 41 ++ .../adapters/__tests__/PrismaAdapter.test.js | 347 ++++++++++++ packages/jobs/src/adapters/index.js | 2 + .../jobs/src/bins/__tests__/runner.test.mjs | 0 .../jobs/src/bins/__tests__/worker.test.mjs | 0 packages/jobs/src/bins/runner.js | 256 +++++++++ packages/jobs/src/bins/worker.js | 111 ++++ packages/jobs/src/core/Executor.js | 45 ++ packages/jobs/src/core/RedwoodJob.js | 199 +++++++ packages/jobs/src/core/Worker.js | 107 ++++ .../jobs/src/core/__tests__/Executor.test.js | 61 ++ .../src/core/__tests__/RedwoodJob.test.js | 525 ++++++++++++++++++ .../jobs/src/core/__tests__/Worker.test.js | 197 +++++++ packages/jobs/src/core/errors.js | 99 ++++ packages/jobs/src/index.ts | 8 + packages/jobs/tsconfig.json | 9 + yarn.lock | 14 + 24 files changed, 2337 insertions(+) create mode 100644 packages/jobs/.babelrc.js create mode 100644 packages/jobs/README.md create mode 100644 packages/jobs/build.mts create mode 100644 packages/jobs/package.json create mode 100644 packages/jobs/src/adapters/BaseAdapter.js create mode 100644 packages/jobs/src/adapters/PrismaAdapter.js create mode 100644 packages/jobs/src/adapters/__tests__/BaseAdapter.test.js create mode 100644 packages/jobs/src/adapters/__tests__/PrismaAdapter.scenarios.js create mode 100644 packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js create mode 100644 packages/jobs/src/adapters/index.js create mode 100644 packages/jobs/src/bins/__tests__/runner.test.mjs create mode 100644 packages/jobs/src/bins/__tests__/worker.test.mjs create mode 100755 packages/jobs/src/bins/runner.js create mode 100755 packages/jobs/src/bins/worker.js create mode 100644 packages/jobs/src/core/Executor.js create mode 100644 packages/jobs/src/core/RedwoodJob.js create mode 100644 packages/jobs/src/core/Worker.js create mode 100644 packages/jobs/src/core/__tests__/Executor.test.js create mode 100644 packages/jobs/src/core/__tests__/RedwoodJob.test.js create mode 100644 packages/jobs/src/core/__tests__/Worker.test.js create mode 100644 packages/jobs/src/core/errors.js create mode 100644 packages/jobs/src/index.ts create mode 100644 packages/jobs/tsconfig.json diff --git a/packages/jobs/.babelrc.js b/packages/jobs/.babelrc.js new file mode 100644 index 000000000000..3b2c815712d9 --- /dev/null +++ b/packages/jobs/.babelrc.js @@ -0,0 +1 @@ +module.exports = { extends: '../../babel.config.js' } diff --git a/packages/jobs/README.md b/packages/jobs/README.md new file mode 100644 index 000000000000..f00fc1b5598a --- /dev/null +++ b/packages/jobs/README.md @@ -0,0 +1,3 @@ +# RedwoodJob + +Provides background job scheduling and processing for Redwood. diff --git a/packages/jobs/build.mts b/packages/jobs/build.mts new file mode 100644 index 000000000000..16175a6725c0 --- /dev/null +++ b/packages/jobs/build.mts @@ -0,0 +1,3 @@ +import { build } from '@redwoodjs/framework-tools' + +await build() diff --git a/packages/jobs/package.json b/packages/jobs/package.json new file mode 100644 index 000000000000..7999752abc85 --- /dev/null +++ b/packages/jobs/package.json @@ -0,0 +1,39 @@ +{ + "name": "@redwoodjs/jobs", + "version": "7.0.0", + "repository": { + "type": "git", + "url": "git+https://github.com/redwoodjs/redwood.git", + "directory": "packages/jobs" + }, + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "bin": { + "rw-jobs": "./dist/bins/runner.js" + }, + "scripts": { + "build": "tsx ./build.mts && yarn build:types", + "build:pack": "yarn pack -o redwoodjs-jobs.tgz", + "build:types": "tsc --build --verbose", + "build:watch": "nodemon --watch src --ext \"js,jsx,ts,tsx\" --ignore dist --exec \"yarn build\"", + "prepublishOnly": "NODE_ENV=production yarn build", + "test": "jest src", + "test:watch": "yarn test --watch" + }, + "dependencies": { + "@babel/runtime-corejs3": "7.24.5", + "core-js": "3.37.1", + "fast-glob": "3.3.2" + }, + "devDependencies": { + "@redwoodjs/project-config": "workspace:*", + "jest": "29.7.0", + "tsx": "4.15.6", + "typescript": "5.4.5" + }, + "gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1" +} diff --git a/packages/jobs/src/adapters/BaseAdapter.js b/packages/jobs/src/adapters/BaseAdapter.js new file mode 100644 index 000000000000..81573f431204 --- /dev/null +++ b/packages/jobs/src/adapters/BaseAdapter.js @@ -0,0 +1,39 @@ +// Base class for all job adapters. Provides a common interface for scheduling +// jobs. At a minimum, you must implement the `schedule` method in your adapter. +// +// Any object passed to the constructor is saved in `this.options` and should +// be used to configure your custom adapter. If `options.logger` is included +// you can access it via `this.logger` + +import { NotImplementedError } from '../core/errors' + +export class BaseAdapter { + constructor(options) { + this.options = options + this.logger = options?.logger + } + + schedule() { + throw new NotImplementedError('schedule') + } + + find() { + throw new NotImplementedError('find') + } + + clear() { + throw new NotImplementedError('clear') + } + + success() { + throw new NotImplementedError('success') + } + + failure() { + throw new NotImplementedError('failure') + } + + #log(message, { level = 'info' }) { + this.logger[level](message) + } +} diff --git a/packages/jobs/src/adapters/PrismaAdapter.js b/packages/jobs/src/adapters/PrismaAdapter.js new file mode 100644 index 000000000000..dbb4b3c2e631 --- /dev/null +++ b/packages/jobs/src/adapters/PrismaAdapter.js @@ -0,0 +1,182 @@ +// Implements a job adapter using Prisma ORM. Assumes a table exists with the +// following schema (the table name and primary key name can be customized): +// +// model BackgroundJob { +// id Int @id @default(autoincrement()) +// attempts Int @default(0) +// handler String +// queue String +// priority Int +// runAt DateTime +// lockedAt DateTime? +// lockedBy String? +// lastError String? +// failedAt DateTime? +// createdAt DateTime @default(now()) +// updatedAt DateTime @updatedAt +// } +// +// Initialize this adapter passing an `accessor` which is the property on an +// instance of PrismaClient that points to the table thats stores the jobs. In +// the above schema, PrismaClient will create a `backgroundJob` property on +// Redwood's `db` instance: +// +// import { db } from 'src/lib/db' +// const adapter = new PrismaAdapter({ accessor: db.backgroundJob }) +// RedwoodJob.config({ adapter }) + +import { camelCase } from 'change-case' + +import { ModelNameError } from '../core/errors' + +import { BaseAdapter } from './BaseAdapter' + +export const DEFAULT_MODEL_NAME = 'BackgroundJob' +export const DEFAULT_MAX_ATTEMPTS = 24 + +export class PrismaAdapter extends BaseAdapter { + constructor(options) { + super(options) + + // instance of PrismaClient + this.db = options.db + + // name of the model as defined in schema.prisma + this.model = options.model || DEFAULT_MODEL_NAME + + // the function to call on `db` to make queries: `db.backgroundJob` + this.accessor = this.db[camelCase(this.model)] + + // the raw table name in the database + // if @@map() is used in the schema then the name will be present in + // db._runtimeDataModel + // otherwise it is the same as the model name + try { + this.tableName = + options.tableName || + this.db._runtimeDataModel.models[this.model].dbName || + this.model + } catch (e) { + // model name must not be right because `this.model` wasn't found in + // `this.db._runtimeDataModel.models` + if (e.name === 'TypeError' && e.message.match("reading 'dbName'")) { + throw new ModelNameError(this.model) + } else { + throw e + } + } + + // the database provider type: 'sqlite' | 'postgresql' | 'mysql' + this.provider = options.db._activeProvider + + this.maxAttempts = options?.maxAttempts || DEFAULT_MAX_ATTEMPTS + } + + // Finds the next job to run, locking it so that no other process can pick it + // The act of locking a job is dependant on the DB server, so we'll run some + // raw SQL to do it in each case—Prisma doesn't provide enough flexibility + // in their generated code to do this in a DB-agnostic way. + find(options) { + switch (this.options.db._activeProvider) { + case 'sqlite': + return this.#sqliteFind(options) + } + } + + success(job) { + return this.accessor.delete({ where: { id: job.id } }) + } + + async failure(job, error) { + const data = { + lockedAt: null, + lockedBy: null, + lastError: `${error.message}\n\n${error.stack}`, + } + + if (job.attempts >= this.maxAttempts) { + data.failedAt = new Date() + data.runAt = null + } else { + data.runAt = new Date( + new Date().getTime() + this.backoffMilliseconds(job.attempts), + ) + } + + return await this.accessor.update({ + where: { id: job.id }, + data, + }) + } + + // Schedules a job by creating a new record in a `BackgroundJob` table + // (or whatever the accessor is configured to point to). + schedule({ handler, args, runAt, queue, priority }) { + return this.accessor.create({ + data: { + handler: JSON.stringify({ handler, args }), + runAt, + queue, + priority, + }, + }) + } + + clear() { + return this.accessor.deleteMany() + } + + backoffMilliseconds(attempts) { + return 1000 * attempts ** 4 + } + + // TODO can this be converted to use standard Prisma queries? + async #sqliteFind({ processName, maxRuntime, queue }) { + const where = ` + ( + ( + ${queue ? `queue = '${queue}' AND` : ''} + runAt <= ${new Date().getTime()} AND ( + lockedAt IS NULL OR + lockedAt < ${new Date(new Date() - maxRuntime).getTime()} + ) OR lockedBy = '${processName}' + ) AND failedAt IS NULL + ) + ` + + // Find any jobs that should run now. Look for ones that: + // - have a runtAt in the past + // - and are either not locked, or were locked more than `maxRuntime` ago + // - or were already locked by this exact process and never cleaned up + // - and don't have a failedAt, meaning we will stop retrying + let outstandingJobs = await this.db.$queryRawUnsafe(` + SELECT id, attempts + FROM ${this.tableName} + WHERE ${where} + ORDER BY priority ASC, runAt ASC + LIMIT 1;`) + + if (outstandingJobs.length) { + const id = outstandingJobs[0].id + + // If one was found, try to lock it by updating the record with the + // same WHERE clause as above (if another locked in the meantime it won't + // find any record to update) + const updatedCount = await this.db.$queryRawUnsafe(` + UPDATE ${this.tableName} + SET lockedAt = ${new Date().getTime()}, + lockedBy = '${processName}', + attempts = ${outstandingJobs[0].attempts + 1} + WHERE ${where} AND id = ${id};`) + + // Assuming the update worked, return the job + if (updatedCount) { + return this.accessor.findFirst({ where: { id } }) + } + } + + // If we get here then there were either no jobs, or the one we found + // was locked by another worker + return null + } +} diff --git a/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js b/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js new file mode 100644 index 000000000000..7fce0b6fbbb9 --- /dev/null +++ b/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js @@ -0,0 +1,49 @@ +import * as errors from '../../core/errors' +import { BaseAdapter } from '../BaseAdapter' + +describe('constructor', () => { + test('initializing the adapter saves options', () => { + const adapter = new BaseAdapter({ foo: 'bar' }) + + expect(adapter.options.foo).toEqual('bar') + }) + + test('creates a separate instance var for any logger', () => { + const mockLogger = jest.fn() + const adapter = new BaseAdapter({ foo: 'bar', logger: mockLogger }) + + expect(adapter.logger).toEqual(mockLogger) + }) +}) + +describe('schedule()', () => { + test('throws an error if not implemented', () => { + const adapter = new BaseAdapter({}) + + expect(() => adapter.schedule()).toThrow(errors.NotImplementedError) + }) +}) + +describe('find()', () => { + test('throws an error if not implemented', () => { + const adapter = new BaseAdapter({}) + + expect(() => adapter.find()).toThrow(errors.NotImplementedError) + }) +}) + +describe('success()', () => { + test('throws an error if not implemented', () => { + const adapter = new BaseAdapter({}) + + expect(() => adapter.success()).toThrow(errors.NotImplementedError) + }) +}) + +describe('failure()', () => { + test('throws an error if not implemented', () => { + const adapter = new BaseAdapter({}) + + expect(() => adapter.failure()).toThrow(errors.NotImplementedError) + }) +}) diff --git a/packages/jobs/src/adapters/__tests__/PrismaAdapter.scenarios.js b/packages/jobs/src/adapters/__tests__/PrismaAdapter.scenarios.js new file mode 100644 index 000000000000..ed7693ddb0c7 --- /dev/null +++ b/packages/jobs/src/adapters/__tests__/PrismaAdapter.scenarios.js @@ -0,0 +1,41 @@ +// import { test, expect } from '@jest/globals' + +export const standard = defineScenario({ + backgroundJob: { + email: { + data: { + id: 1, + handler: JSON.stringify({ handler: 'EmailJob', args: [123] }), + queue: 'email', + priority: 50, + runAt: '2021-04-30T15:35:19Z', + }, + }, + + multipleAttempts: { + data: { + id: 2, + attempts: 10, + handler: JSON.stringify({ handler: 'TestJob', args: [123] }), + queue: 'default', + priority: 50, + runAt: '2021-04-30T15:35:19Z', + }, + }, + + maxAttempts: { + data: { + id: 3, + attempts: 24, + handler: JSON.stringify({ handler: 'TestJob', args: [123] }), + queue: 'default', + priority: 50, + runAt: '2021-04-30T15:35:19Z', + }, + }, + }, +}) + +// test('truth', () => { +// expect(true) +// }) diff --git a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js new file mode 100644 index 000000000000..e4e2f07a7c45 --- /dev/null +++ b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js @@ -0,0 +1,347 @@ +import { db } from 'src/lib/db' + +import * as errors from '../../core/errors' +import { + PrismaAdapter, + DEFAULT_MODEL_NAME, + DEFAULT_MAX_ATTEMPTS, +} from '../PrismaAdapter' + +jest.useFakeTimers().setSystemTime(new Date('2024-01-01')) + +describe('constructor', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('defaults this.model name', () => { + const adapter = new PrismaAdapter({ db }) + + expect(adapter.model).toEqual(DEFAULT_MODEL_NAME) + }) + + test('can manually set this.model', () => { + const dbMock = jest.fn(() => ({ + _runtimeDataModel: { + models: { + Job: { + dbName: null, + }, + }, + }, + job: {}, + })) + const adapter = new PrismaAdapter({ + db: dbMock(), + model: 'Job', + }) + + expect(adapter.model).toEqual('Job') + }) + + test('throws an error with a model name that does not exist', () => { + expect(() => new PrismaAdapter({ db, model: 'FooBar' })).toThrow( + errors.ModelNameError, + ) + }) + + test('sets this.accessor to the correct Prisma accessor', () => { + const adapter = new PrismaAdapter({ db }) + + expect(adapter.accessor).toEqual(db.backgroundJob) + }) + + test('manually set this.tableName ', () => { + const adapter = new PrismaAdapter({ db, tableName: 'background_jobz' }) + + expect(adapter.tableName).toEqual('background_jobz') + }) + + test('set this.tableName from custom @@map() name in schema', () => { + const dbMock = jest.fn(() => ({ + _runtimeDataModel: { + models: { + BackgroundJob: { + dbName: 'bg_jobs', + }, + }, + }, + })) + const adapter = new PrismaAdapter({ + db: dbMock(), + }) + + expect(adapter.tableName).toEqual('bg_jobs') + }) + + test('default this.tableName to camelCase version of model name', () => { + const adapter = new PrismaAdapter({ db }) + + expect(adapter.tableName).toEqual('BackgroundJob') + }) + + test('sets this.provider based on the active provider', () => { + const adapter = new PrismaAdapter({ db }) + + expect(adapter.provider).toEqual('sqlite') + }) + + test('defaults this.maxAttempts', () => { + const adapter = new PrismaAdapter({ db }) + + expect(adapter.maxAttempts).toEqual(DEFAULT_MAX_ATTEMPTS) + }) + + test('can manually set this.maxAttempts', () => { + const adapter = new PrismaAdapter({ db, maxAttempts: 10 }) + + expect(adapter.maxAttempts).toEqual(10) + }) +}) + +describe('schedule()', () => { + afterEach(async () => { + await db.backgroundJob.deleteMany() + }) + + test('creates a job in the DB', async () => { + const adapter = new PrismaAdapter({ db }) + const beforeJobCount = await db.backgroundJob.count() + await adapter.schedule({ + handler: 'RedwoodJob', + args: ['foo', 'bar'], + queue: 'default', + priority: 50, + runAt: new Date(), + }) + const afterJobCount = await db.backgroundJob.count() + + expect(afterJobCount).toEqual(beforeJobCount + 1) + }) + + test('returns the job record that was created', async () => { + const adapter = new PrismaAdapter({ db }) + const job = await adapter.schedule({ + handler: 'RedwoodJob', + args: ['foo', 'bar'], + queue: 'default', + priority: 50, + runAt: new Date(), + }) + + expect(job.handler).toEqual('{"handler":"RedwoodJob","args":["foo","bar"]}') + expect(job.runAt).toEqual(new Date()) + expect(job.queue).toEqual('default') + expect(job.priority).toEqual(50) + }) + + test('makes no attempt to de-dupe jobs', async () => { + const adapter = new PrismaAdapter({ db }) + const job1 = await adapter.schedule({ + handler: 'RedwoodJob', + args: ['foo', 'bar'], + queue: 'default', + priority: 50, + runAt: new Date(), + }) + const job2 = await adapter.schedule({ + handler: 'RedwoodJob', + args: ['foo', 'bar'], + queue: 'default', + priority: 50, + runAt: new Date(), + }) + + // definitely a different record in the DB + expect(job1.id).not.toEqual(job2.id) + // but all details are identical + expect(job1.handler).toEqual(job2.handler) + expect(job1.queue).toEqual(job2.queue) + expect(job1.priority).toEqual(job2.priority) + }) + + test('defaults some database fields', async () => { + const adapter = new PrismaAdapter({ db }) + const job = await adapter.schedule({ + handler: 'RedwoodJob', + args: ['foo', 'bar'], + queue: 'default', + priority: 50, + runAt: new Date(), + }) + + expect(job.attempts).toEqual(0) + expect(job.lockedAt).toBeNull() + expect(job.lockedBy).toBeNull() + expect(job.lastError).toBeNull() + expect(job.failedAt).toBeNull() + }) +}) + +describe('find()', () => { + // TODO add more tests for all the various WHERE conditions when finding a job + + scenario('returns null if no job found', async () => { + const adapter = new PrismaAdapter({ db }) + const job = await adapter.find({ + processName: 'test', + maxRuntime: 1000, + queue: 'foobar', + }) + expect(job).toBeNull() + }) + + scenario('returns a job if conditions met', async (scenario) => { + const adapter = new PrismaAdapter({ db }) + const job = await adapter.find({ + processName: 'test', + maxRuntime: 1000, + queue: scenario.backgroundJob.email.queue, + }) + expect(job.id).toEqual(scenario.backgroundJob.email.id) + }) + + scenario( + 'increments the `attempts` count on the found job', + async (scenario) => { + const adapter = new PrismaAdapter({ db }) + const job = await adapter.find({ + processName: 'test', + maxRuntime: 1000, + queue: scenario.backgroundJob.email.queue, + }) + expect(job.attempts).toEqual(scenario.backgroundJob.email.attempts + 1) + }, + ) + + scenario('locks the job for the current process', async (scenario) => { + const adapter = new PrismaAdapter({ db }) + const job = await adapter.find({ + processName: 'test-process', + maxRuntime: 1000, + queue: scenario.backgroundJob.email.queue, + }) + expect(job.lockedBy).toEqual('test-process') + }) + + scenario('locks the job with a current timestamp', async (scenario) => { + const adapter = new PrismaAdapter({ db }) + const job = await adapter.find({ + processName: 'test-process', + maxRuntime: 1000, + queue: scenario.backgroundJob.email.queue, + }) + expect(job.lockedAt).toEqual(new Date()) + }) +}) + +describe('success()', () => { + scenario('deletes the job from the DB', async (scenario) => { + const adapter = new PrismaAdapter({ db }) + const job = await adapter.success(scenario.backgroundJob.email) + const dbJob = await db.backgroundJob.findFirst({ + where: { id: job.id }, + }) + + expect(dbJob).toBeNull() + }) +}) + +describe('failure()', () => { + scenario('clears the lock fields', async (scenario) => { + const adapter = new PrismaAdapter({ db }) + await adapter.failure( + scenario.backgroundJob.multipleAttempts, + new Error('test error'), + ) + const dbJob = await db.backgroundJob.findFirst({ + where: { id: scenario.backgroundJob.multipleAttempts.id }, + }) + + expect(dbJob.lockedAt).toBeNull() + expect(dbJob.lockedBy).toBeNull() + }) + + scenario( + 'reschedules the job at a designated backoff time', + async (scenario) => { + const adapter = new PrismaAdapter({ db }) + await adapter.failure( + scenario.backgroundJob.multipleAttempts, + new Error('test error'), + ) + const dbJob = await db.backgroundJob.findFirst({ + where: { id: scenario.backgroundJob.multipleAttempts.id }, + }) + + expect(dbJob.runAt).toEqual( + new Date( + new Date().getTime() + + 1000 * scenario.backgroundJob.multipleAttempts.attempts ** 4, + ), + ) + }, + ) + + scenario('records the error', async (scenario) => { + const adapter = new PrismaAdapter({ db }) + await adapter.failure( + scenario.backgroundJob.multipleAttempts, + new Error('test error'), + ) + const dbJob = await db.backgroundJob.findFirst({ + where: { id: scenario.backgroundJob.multipleAttempts.id }, + }) + + expect(dbJob.lastError).toContain('test error\n\n') + }) + + scenario( + 'marks the job as failed if max attempts reached', + async (scenario) => { + const adapter = new PrismaAdapter({ db }) + await adapter.failure( + scenario.backgroundJob.maxAttempts, + new Error('test error'), + ) + const dbJob = await db.backgroundJob.findFirst({ + where: { id: scenario.backgroundJob.maxAttempts.id }, + }) + + expect(dbJob.failedAt).toEqual(new Date()) + }, + ) + + scenario('nullifies runtAt if max attempts reached', async (scenario) => { + const adapter = new PrismaAdapter({ db }) + await adapter.failure( + scenario.backgroundJob.maxAttempts, + new Error('test error'), + ) + const dbJob = await db.backgroundJob.findFirst({ + where: { id: scenario.backgroundJob.maxAttempts.id }, + }) + + expect(dbJob.runAt).toBeNull() + }) +}) + +describe('clear()', () => { + scenario('deletes all jobs from the DB', async () => { + const adapter = new PrismaAdapter({ db }) + await adapter.clear() + const jobCount = await db.backgroundJob.count() + + expect(jobCount).toEqual(0) + }) +}) + +describe('backoffMilliseconds()', () => { + test('returns the number of milliseconds to wait for the next run', () => { + expect(new PrismaAdapter({ db }).backoffMilliseconds(0)).toEqual(0) + expect(new PrismaAdapter({ db }).backoffMilliseconds(1)).toEqual(1000) + expect(new PrismaAdapter({ db }).backoffMilliseconds(2)).toEqual(16000) + expect(new PrismaAdapter({ db }).backoffMilliseconds(3)).toEqual(81000) + expect(new PrismaAdapter({ db }).backoffMilliseconds(20)).toEqual(160000000) + }) +}) diff --git a/packages/jobs/src/adapters/index.js b/packages/jobs/src/adapters/index.js new file mode 100644 index 000000000000..77669ba2f239 --- /dev/null +++ b/packages/jobs/src/adapters/index.js @@ -0,0 +1,2 @@ +export { BaseAdapter } from './BaseAdapter' +export { PrismaAdapter } from './PrismaAdapter' diff --git a/packages/jobs/src/bins/__tests__/runner.test.mjs b/packages/jobs/src/bins/__tests__/runner.test.mjs new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/jobs/src/bins/__tests__/worker.test.mjs b/packages/jobs/src/bins/__tests__/worker.test.mjs new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/jobs/src/bins/runner.js b/packages/jobs/src/bins/runner.js new file mode 100755 index 000000000000..248c51938d22 --- /dev/null +++ b/packages/jobs/src/bins/runner.js @@ -0,0 +1,256 @@ +#!/usr/bin/env node + +// Coordinates the worker processes: running attached in [work] mode or +// detaching in [start] mode. + +import { fork, exec } from 'node:child_process' + +import { hideBin } from 'yargs/helpers' +import yargs from 'yargs/yargs' + +import { loadEnvFiles } from '@redwoodjs/cli/dist/lib/loadEnvFiles' + +import { logger } from './lib/logger.js' +loadEnvFiles() + +process.title = 'rw-job-runner' + +const parseArgs = (argv) => { + const parsed = yargs(hideBin(argv)) + .usage( + 'Starts the RedwoodJob runner to process background jobs\n\nUsage: $0 [options]' + ) + .command('work', 'Start a worker and process jobs') + .command('workoff', 'Start a worker and exit after all jobs processed') + .command('start', 'Start workers in daemon mode', (yargs) => { + yargs + .option('n', { + type: 'string', + describe: + 'Number of workers to start OR queue:num pairs of workers to start (see examples)', + default: '1', + }) + .example( + '$0 start -n 2', + 'Start the job runner with 2 workers in daemon mode' + ) + .example( + '$0 start -n default:2,email:1', + 'Start the job runner in daemon mode with 2 workers for the "default" queue and 1 for the "email" queue' + ) + }) + .command('stop', 'Stop any daemonized job workers') + .command( + 'restart', + 'Stop and start any daemonized job workers', + (yargs) => { + yargs + .option('n', { + type: 'string', + describe: + 'Number of workers to start OR queue:num pairs of workers to start (see examples)', + default: '1', + }) + .example( + '$0 restart -n 2', + 'Restart the job runner with 2 workers in daemon mode' + ) + .example( + '$0 restart -n default:2,email:1', + 'Restart the job runner in daemon mode with 2 workers for the `default` queue and 1 for the `email` queue' + ) + } + ) + .command('clear', 'Clear the job queue') + .demandCommand(1, 'You must specify a mode to start in') + .example( + '$0 start -n 2', + 'Start the job runner with 2 workers in daemon mode' + ) + .example( + '$0 start -n default:2,email:1', + 'Start the job runner in daemon mode with 2 workers for the "default" queue and 1 for the "email" queue' + ) + .help().argv + + return { numWorkers: parsed.n, command: parsed._[0] } +} + +const buildWorkerConfig = (numWorkers) => { + // Builds up an array of arrays, with queue name and id: + // `-n default:2,email:1` => [ ['default', 0], ['default', 1], ['email', 0] ] + // If only given a number of workers then queue name is an empty string: + // `-n 2` => [ ['', 0], ['', 1] ] + let workers = [] + + // default to one worker for commands that don't specify + if (!numWorkers) { + numWorkers = '1' + } + + // if only a number was given, convert it to a nameless worker: `2` => `:2` + if (!isNaN(parseInt(numWorkers))) { + numWorkers = `:${numWorkers}` + } + + // split the queue:num pairs and build the workers array + numWorkers.split(',').forEach((count) => { + const [queue, num] = count.split(':') + for (let i = 0; i < parseInt(num); i++) { + workers.push([queue || null, i]) + } + }) + + return workers +} + +const startWorkers = ({ workerConfig, detach = false, workoff = false }) => { + logger.warn(`Starting ${workerConfig.length} worker(s)...`) + + return workerConfig.map(([queue, id], i) => { + // list of args to send to the forked worker script + const workerArgs = ['--id', id] + + // add the queue name if present + if (queue) { + workerArgs.push('--queue', queue) + } + + // are we in workoff mode? + if (workoff) { + workerArgs.push('--workoff') + } + + // fork the worker process + const worker = fork('api/dist/worker.js', workerArgs, { + detached: detach, + stdio: detach ? 'ignore' : 'inherit', + }) + + if (detach) { + worker.unref() + } else { + // children stay attached so watch for their exit + worker.on('exit', (_code) => {}) + } + + return worker + }) +} + +const signalSetup = (workers) => { + // if we get here then we're still monitoring workers and have to pass on signals + let sigtermCount = 0 + + // If the parent receives a ctrl-c, tell each worker to gracefully exit. + // If the parent receives a second ctrl-c, exit immediately. + process.on('SIGINT', () => { + sigtermCount++ + let message = + 'SIGINT received: shutting down workers gracefully (press Ctrl-C again to exit immediately)...' + + if (sigtermCount > 1) { + message = 'SIGINT received again, exiting immediately...' + } + + logger.info(message) + + workers.forEach((worker) => { + sigtermCount > 1 ? worker.kill('SIGTERM') : worker.kill('SIGINT') + }) + }) +} + +const findProcessId = async (proc) => { + return new Promise(function (resolve, reject) { + const plat = process.platform + const cmd = + plat === 'win32' + ? 'tasklist' + : plat === 'darwin' + ? 'ps -ax | grep ' + proc + : plat === 'linux' + ? 'ps -A' + : '' + if (cmd === '' || proc === '') { + resolve(false) + } + exec(cmd, function (err, stdout, _stderr) { + if (err) { + reject(err) + } + + const list = stdout.trim().split('\n') + const matches = list.filter((line) => { + if (plat == 'darwin' || plat == 'linux') { + return !line.match('grep') + } + return true + }) + if (matches.length === 0) { + resolve(false) + } else { + resolve(parseInt(matches[0].split(' ')[0])) + } + }) + }) +} + +// TODO add support for stopping with SIGTERM or SIGKILL? +const stopWorkers = async ({ workerConfig, signal = 'SIGINT' }) => { + logger.warn( + `Stopping ${workerConfig.length} worker(s) gracefully (${signal})...` + ) + + for (const [queue, id] of workerConfig) { + const workerTitle = `rw-job-worker${queue ? `.${queue}` : ''}.${id}` + const processId = await findProcessId(workerTitle) + + if (!processId) { + logger.warn(`No worker found with title ${workerTitle}`) + continue + } + + logger.info( + `Stopping worker ${workerTitle} with process id ${processId}...` + ) + process.kill(processId, signal) + + // wait for the process to actually exit before going to next iteration + while (await findProcessId(workerTitle)) { + await new Promise((resolve) => setTimeout(resolve, 250)) + } + } +} + +const clearQueue = () => { + logger.warn(`Starting worker to clear job queue...`) + fork('api/dist/worker.js', ['--clear']) +} + +const main = async () => { + const { numWorkers, command } = parseArgs(process.argv) + const workerConfig = buildWorkerConfig(numWorkers) + + logger.warn(`Starting RedwoodJob Runner at ${new Date().toISOString()}...`) + + switch (command) { + case 'start': + startWorkers({ workerConfig, detach: true }) + return process.exit(0) + case 'restart': + await stopWorkers({ workerConfig, signal: 2 }) + startWorkers({ workerConfig, detach: true }) + return process.exit(0) + case 'work': + return signalSetup(startWorkers({ workerConfig })) + case 'workoff': + return signalSetup(startWorkers({ workerConfig, workoff: true })) + case 'stop': + return await stopWorkers({ workerConfig, signal: 'SIGINT' }) + case 'clear': + return clearQueue() + } +} + +main() diff --git a/packages/jobs/src/bins/worker.js b/packages/jobs/src/bins/worker.js new file mode 100755 index 000000000000..064f93eda6fa --- /dev/null +++ b/packages/jobs/src/bins/worker.js @@ -0,0 +1,111 @@ +#!/usr/bin/env node + +// The process that actually starts an instance of Worker to process jobs. + +import { hideBin } from 'yargs/helpers' +import yargs from 'yargs/yargs' + +import { loadEnvFiles } from '@redwoodjs/cli/dist/lib/loadEnvFiles' +loadEnvFiles() + +import { Worker } from '../core/Worker' + +// TODO import from app somehow? +import { adapter } from './lib/jobs' +import { logger } from './lib/logger.js' + +const TITLE_PREFIX = `rw-job-worker` + +const parseArgs = (argv) => { + return yargs(hideBin(argv)) + .usage( + 'Starts a single RedwoodJob worker to process background jobs\n\nUsage: $0 [options]', + ) + .option('i', { + alias: 'id', + type: 'number', + description: 'The worker ID', + default: 0, + }) + .option('q', { + alias: 'queue', + type: 'string', + description: 'The named queue to work on', + }) + .option('o', { + alias: 'workoff', + type: 'boolean', + default: false, + description: 'Work off all jobs in the queue and exit', + }) + .option('c', { + alias: 'clear', + type: 'boolean', + default: false, + description: 'Remove all jobs in the queue and exit', + }) + .help().argv +} + +const setProcessTitle = ({ id, queue }) => { + // set the process title + let title = TITLE_PREFIX + if (queue) { + title += `.${queue}.${id}` + } else { + title += `.${id}` + } + process.title = title +} + +const setupSignals = (worker) => { + // if the parent itself receives a ctrl-c it'll pass that to the workers. + // workers will exit gracefully by setting `forever` to `false` which will tell + // it not to pick up a new job when done with the current one + process.on('SIGINT', () => { + logger.warn( + { worker: process.title }, + `SIGINT received at ${new Date().toISOString()}, finishing work...`, + ) + worker.forever = false + }) + + // if the parent itself receives a ctrl-c more than once it'll send SIGTERM + // instead in which case we exit immediately no matter what state the worker is + // in + process.on('SIGTERM', () => { + logger.info( + { worker: process.title }, + `SIGTERM received at ${new Date().toISOString()}, exiting now!`, + ) + process.exit(0) + }) +} + +const main = async () => { + const { id, queue, clear, workoff } = parseArgs(process.argv) + setProcessTitle({ id, queue }) + + logger.info( + { worker: process.title }, + `Starting work at ${new Date().toISOString()}...`, + ) + + const worker = new Worker({ + adapter, + processName: process.title, + logger, + queue, + workoff, + clear, + }) + + worker.run().then(() => { + logger.info({ worker: process.title }, `Worker finished, shutting down.`) + process.exit(0) + }) + + setupSignals(worker) +} + +main() diff --git a/packages/jobs/src/core/Executor.js b/packages/jobs/src/core/Executor.js new file mode 100644 index 000000000000..241f258ce702 --- /dev/null +++ b/packages/jobs/src/core/Executor.js @@ -0,0 +1,45 @@ +// Used by the job runner to execute a job and track success or failure + +import fg from 'fast-glob' + +import { + AdapterRequiredError, + JobRequiredError, + JobNotFoundError, +} from './errors' + +export class Executor { + constructor(options) { + this.options = options + this.adapter = options?.adapter + this.job = options?.job + this.logger = options?.logger || console + + if (!this.adapter) { + throw new AdapterRequiredError() + } + if (!this.job) { + throw new JobRequiredError() + } + } + + async perform() { + this.logger.info(this.job, `Started job ${this.job.id}`) + + try { + const details = JSON.parse(this.job.handler) + const entries = await fg(`./**/${details.handler}.js`, { cwd: __dirname }) + if (!entries[0]) { + throw new JobNotFoundError(details.handler) + } + + const Job = await import(`./${entries[0]}`) + await new Job[details.handler]().perform(...details.args) + + return this.adapter.success(this.job) + } catch (e) { + this.logger.error(e.stack) + return this.adapter.failure(this.job, e) + } + } +} diff --git a/packages/jobs/src/core/RedwoodJob.js b/packages/jobs/src/core/RedwoodJob.js new file mode 100644 index 000000000000..9c2c957146a8 --- /dev/null +++ b/packages/jobs/src/core/RedwoodJob.js @@ -0,0 +1,199 @@ +// Base class for all jobs, providing a common interface for scheduling jobs. +// At a minimum you must implement the `perform` method in your job subclass. + +import { + AdapterNotConfiguredError, + PerformNotImplementedError, + SchedulingError, + PerformError, +} from './errors' + +export const DEFAULT_QUEUE = 'default' + +export class RedwoodJob { + // The default queue for all jobs + static queue = DEFAULT_QUEUE + + // The default priority for all jobs + // Assumes a range of 1 - 100, 1 being highest priority + static priority = 50 + + // The adapter to use for scheduling jobs. Set via the static `config` method + static adapter + + // Set via the static `config` method + static logger + + // Configure all jobs to use a specific adapter + static config(options) { + if (options) { + if (Object.keys(options).includes('adapter')) { + this.adapter = options.adapter + } + if (Object.keys(options).includes('logger')) { + this.logger = options.logger + } + } + } + + // Class method to schedule a job to run later + // const scheduleDetails = RedwoodJob.performLater('foo', 'bar') + static performLater(...args) { + return new this().performLater(...args) + } + + // Class method to run the job immediately in the current process + // const result = RedwoodJob.performNow('foo', 'bar') + static performNow(...args) { + return new this().performNow(...args) + } + + // Set options on the job before enqueueing it: + // const job = RedwoodJob.set({ wait: 300 }) + // job.performLater('foo', 'bar') + static set(options) { + return new this().set(options) + } + + // Private property to store options set on the job + #options = {} + + // A job can be instantiated manually, but this will also be invoked + // automatically by .set() or .performLater() + constructor(options) { + this.set(options) + } + + // Set options on the job before enqueueing it: + // const job = RedwoodJob.set({ wait: 300 }) + // job.performLater('foo', 'bar') + set(options = {}) { + this.#options = { queue: this.queue, priority: this.priority, ...options } + return this + } + + // Instance method to schedule a job to run later + // const job = RedwoodJob + // const scheduleDetails = job.performLater('foo', 'bar') + performLater(...args) { + this.logger.info( + this.payload(args), + `[RedwoodJob] Scheduling ${this.constructor.name}`, + ) + + return this.#schedule(args) + } + + // Instance method to runs the job immediately in the current process + // const result = RedwoodJob.performNow('foo', 'bar') + async performNow(...args) { + this.logger.info( + this.payload(args), + `[RedwoodJob] Running ${this.constructor.name} now`, + ) + + try { + return await this.perform(...args) + } catch (e) { + if (e instanceof PerformNotImplementedError) { + throw e + } else { + throw new PerformError( + `[${this.constructor.name}] exception when running job`, + e, + ) + } + } + } + + // Must be implemented by the subclass + perform() { + throw new PerformNotImplementedError() + } + + // Returns data sent to the adapter for scheduling + payload(args) { + return { + handler: this.constructor.name, + args, + runAt: this.runAt, + queue: this.queue, + priority: this.priority, + } + } + + get logger() { + return this.#options?.logger || this.constructor.logger + } + + // Determines the name of the queue + get queue() { + return this.#options?.queue || this.constructor.queue + } + + // Set the name of the queue directly on an instance of a job + set queue(value) { + this.#options = Object.assign(this.#options || {}, { queue: value }) + } + + // Determines the priority of the job + get priority() { + return this.#options?.priority || this.constructor.priority + } + + // Set the priority of the job directly on an instance of a job + set priority(value) { + this.#options = Object.assign(this.#options || {}, { + priority: value, + }) + } + + // Determines when the job should run. + // + // * If no options were set, defaults to running as soon as possible + // * If a `wait` option is present it sets the number of seconds to wait + // * If a `waitUntil` option is present it runs at that specific datetime + get runAt() { + if (!this.#options?.runAt) { + this.#options = Object.assign(this.#options || {}, { + runAt: this.#options?.wait + ? new Date(new Date().getTime() + this.#options.wait * 1000) + : this.#options?.waitUntil + ? this.#options.waitUntil + : new Date(), + }) + } + + return this.#options.runAt + } + + // Set the runAt time on a job directly: + // const job = new RedwoodJob() + // job.runAt = new Date(2030, 1, 2, 12, 34, 56) + // job.performLater() + set runAt(value) { + this.#options = Object.assign(this.#options || {}, { runAt: value }) + } + + // Make private this.#options available as a getter only + get options() { + return this.#options + } + + // Private, schedules a job with the appropriate adapter, returns whatever + // the adapter returns in response to a successful schedule. + async #schedule(args) { + if (!this.constructor.adapter) { + throw new AdapterNotConfiguredError() + } + + try { + return await this.constructor.adapter.schedule(this.payload(args)) + } catch (e) { + throw new SchedulingError( + `[RedwoodJob] Exception when scheduling ${this.constructor.name}`, + e, + ) + } + } +} diff --git a/packages/jobs/src/core/Worker.js b/packages/jobs/src/core/Worker.js new file mode 100644 index 000000000000..dd3340012679 --- /dev/null +++ b/packages/jobs/src/core/Worker.js @@ -0,0 +1,107 @@ +// Used by the job runner to find the next job to run and invoke the Executor + +import { AdapterRequiredError } from './errors' +import { Executor } from './Executor' + +export const DEFAULT_WAIT_TIME = 5000 // 5 seconds +export const DEFAULT_MAX_RUNTIME = 60 * 60 * 4 * 1000 // 4 hours + +export class Worker { + constructor(options) { + this.options = options + this.adapter = options?.adapter + this.logger = options?.logger || console + + // if true, will clear the queue of all jobs and then exit + this.clear = options?.clear || false + + // used to set the `lockedBy` field in the database + this.processName = options?.processName || process.title + + // if not given a queue name then will work on jobs in any queue + this.queue = options?.queue || null + + // the maximum amount of time to let a job run + this.maxRuntime = + options?.maxRuntime === undefined + ? DEFAULT_MAX_RUNTIME + : options.maxRuntime + + // the amount of time to wait between checking for jobs. the time it took + // to run a job is subtracted from this time, so this is a maximum wait time + this.waitTime = + options?.waitTime === undefined ? DEFAULT_WAIT_TIME : options.waitTime + + // keep track of the last time we checked for jobs + this.lastCheckTime = new Date() + + // Set to `false` if the work loop should only run one time, regardless + // of how many outstanding jobs there are to be worked on. The worker + // process will set this to `false` as soon as the user hits ctrl-c so + // any current job will complete before exiting. + this.forever = options?.forever === undefined ? true : options.forever + + // Set to `true` if the work loop should run through all *available* jobs + // and then quit. Serves a slightly different purpose than `forever` which + // makes the runner exit immediately after the next loop, where as `workoff` + // doesn't exit the loop until there are no more jobs to work on. + this.workoff = options?.workoff === undefined ? false : options.workoff + + if (!this.adapter) { + throw new AdapterRequiredError() + } + } + + // Workers run forever unless: + // `this.forever` to false (loop only runs once, then exits) + // `this.workoff` is true (run all jobs in the queue, then exits) + run() { + if (this.clear) { + return this.#clearQueue() + } else { + return this.#work() + } + } + + async #clearQueue() { + return await this.adapter.clear() + } + + async #work() { + do { + this.lastCheckTime = new Date() + + this.logger.debug(`[${this.processName}] Checking for jobs...`) + + const job = await this.adapter.find({ + processName: this.processName, + maxRuntime: this.maxRuntime, + queue: this.queue, + }) + + if (job) { + // TODO add timeout handling if runs for more than `this.maxRuntime` + await new Executor({ + adapter: this.adapter, + job, + logger: this.logger, + }).perform() + } else if (this.workoff) { + // If there are no jobs and we're in workoff mode, we're done + break + } + + // sleep if there were no jobs found, otherwise get back to work + if (!job && this.forever) { + const millsSinceLastCheck = new Date() - this.lastCheckTime + if (millsSinceLastCheck < this.waitTime) { + await this.#wait(this.waitTime - millsSinceLastCheck) + } + } + } while (this.forever) + } + + #wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) + } +} diff --git a/packages/jobs/src/core/__tests__/Executor.test.js b/packages/jobs/src/core/__tests__/Executor.test.js new file mode 100644 index 000000000000..bc703a31ec75 --- /dev/null +++ b/packages/jobs/src/core/__tests__/Executor.test.js @@ -0,0 +1,61 @@ +import * as errors from '../../core/errors' +import { Executor } from '../Executor' + +describe('constructor', () => { + test('saves options', () => { + const options = { adapter: 'adapter', job: 'job' } + const exector = new Executor(options) + + expect(exector.options).toEqual(options) + }) + + test('extracts adapter from options to variable', () => { + const options = { adapter: 'adapter', job: 'job' } + const exector = new Executor(options) + + expect(exector.adapter).toEqual('adapter') + }) + + test('extracts job from options to variable', () => { + const options = { adapter: 'adapter', job: 'job' } + const exector = new Executor(options) + + expect(exector.job).toEqual('job') + }) + + test('throws AdapterRequiredError if adapter is not provided', () => { + const options = { job: 'job' } + + expect(() => new Executor(options)).toThrow(errors.AdapterRequiredError) + }) + + test('throws JobRequiredError if job is not provided', () => { + const options = { adapter: 'adapter' } + + expect(() => new Executor(options)).toThrow(errors.JobRequiredError) + }) +}) + +describe('perform', () => { + test.skip('invokes the `perform` method on the job class', async () => { + const options = { + adapter: 'adapter', + job: { handler: JSON.stringify({ handler: 'Foo', args: ['bar'] }) }, + } + const executor = new Executor(options) + const job = { id: 1 } + + const mockJob = jest.fn(() => { + return { perform: jest.fn() } + }) + jest.mock(`../Foo`, () => ({ Foo: mockJob }), { virtual: true }) + + await executor.perform(job) + + expect(mockJob).toHaveBeenCalledWith('bar') + }) + + test.skip('invokes the `success` method on the adapter when job successful', async () => {}) + + test.skip('invokes the `failure` method on the adapter when job fails', async () => {}) +}) diff --git a/packages/jobs/src/core/__tests__/RedwoodJob.test.js b/packages/jobs/src/core/__tests__/RedwoodJob.test.js new file mode 100644 index 000000000000..fb0a320fbc10 --- /dev/null +++ b/packages/jobs/src/core/__tests__/RedwoodJob.test.js @@ -0,0 +1,525 @@ +import * as errors from '../../core/errors' +import { RedwoodJob } from '../RedwoodJob' + +jest.useFakeTimers().setSystemTime(new Date('2024-01-01')) + +describe('static config', () => { + test('can set the adapter', () => { + const adapter = { schedule: jest.fn() } + + RedwoodJob.config({ adapter }) + + expect(RedwoodJob.adapter).toEqual(adapter) + }) + + test('can set the logger', () => { + const logger = { info: jest.fn() } + + RedwoodJob.config({ logger }) + + expect(RedwoodJob.logger).toEqual(logger) + }) + + test('can explictly set the adapter to falsy values for testing', () => { + RedwoodJob.config({ adapter: null }) + expect(RedwoodJob.adapter).toBeNull() + + RedwoodJob.config({ adapter: undefined }) + expect(RedwoodJob.adapter).toBeUndefined() + + RedwoodJob.config({ adapter: false }) + expect(RedwoodJob.adapter).toEqual(false) + }) +}) + +describe('constructor()', () => { + test('returns an instance of the job', () => { + const job = new RedwoodJob() + expect(job).toBeInstanceOf(RedwoodJob) + }) + + test('defaults some options', () => { + const job = new RedwoodJob() + expect(job.options).toEqual({ + queue: RedwoodJob.queue, + priority: RedwoodJob.priority, + }) + }) + + test('can set options for the job', () => { + const job = new RedwoodJob({ foo: 'bar' }) + expect(job.options.foo).toEqual('bar') + }) +}) + +describe('static set()', () => { + test('returns a job instance', () => { + const job = RedwoodJob.set({ wait: 300 }) + + expect(job).toBeInstanceOf(RedwoodJob) + }) + + test('sets options for the job', () => { + const job = RedwoodJob.set({ foo: 'bar' }) + + expect(job.options.foo).toEqual('bar') + }) + + test('sets the default queue', () => { + const job = RedwoodJob.set({ foo: 'bar' }) + + expect(job.options.queue).toEqual(RedwoodJob.queue) + }) + + test('sets the default priority', () => { + const job = RedwoodJob.set({ foo: 'bar' }) + + expect(job.options.priority).toEqual(RedwoodJob.priority) + }) + + test('can override the queue name set in the class', () => { + const job = RedwoodJob.set({ foo: 'bar', queue: 'priority' }) + + expect(job.options.queue).toEqual('priority') + }) + + test('can override the priority set in the class', () => { + const job = RedwoodJob.set({ foo: 'bar', priority: 10 }) + + expect(job.options.priority).toEqual(10) + }) +}) + +describe('instance set()', () => { + test('returns a job instance', () => { + const job = new RedwoodJob().set({ wait: 300 }) + + expect(job).toBeInstanceOf(RedwoodJob) + }) + + test('sets options for the job', () => { + const job = new RedwoodJob().set({ foo: 'bar' }) + + expect(job.options.foo).toEqual('bar') + }) + + test('sets the default queue', () => { + const job = new RedwoodJob().set({ foo: 'bar' }) + + expect(job.options.queue).toEqual(RedwoodJob.queue) + }) + + test('sets the default priority', () => { + const job = new RedwoodJob().set({ foo: 'bar' }) + + expect(job.options.priority).toEqual(RedwoodJob.priority) + }) + + test('can override the queue name set in the class', () => { + const job = new RedwoodJob().set({ foo: 'bar', queue: 'priority' }) + + expect(job.options.queue).toEqual('priority') + }) + + test('can override the priority set in the class', () => { + const job = new RedwoodJob().set({ foo: 'bar', priority: 10 }) + + expect(job.options.priority).toEqual(10) + }) +}) + +describe('get runAt()', () => { + test('returns the current time if no options are set', () => { + const job = new RedwoodJob() + + expect(job.runAt).toEqual(new Date()) + }) + + test.only('returns a datetime `wait` seconds in the future if option set', async () => { + const job = RedwoodJob.set({ wait: 300 }) + + console.info(job.runAt) + + expect(job.runAt).toEqual(new Date(new Date().getTime() + 300 * 1000)) + }) + + test('returns a datetime set to `waitUntil` if option set', async () => { + const futureDate = new Date(2030, 1, 2, 12, 34, 56) + const job = RedwoodJob.set({ + waitUntil: futureDate, + }) + + expect(job.runAt).toEqual(futureDate) + }) + + test('returns any datetime set directly on the instance', () => { + const futureDate = new Date(2030, 1, 2, 12, 34, 56) + const job = new RedwoodJob() + job.runAt = futureDate + + expect(job.runAt).toEqual(futureDate) + }) + + test('sets the computed time in the `options` property', () => { + const job = new RedwoodJob() + const runAt = job.runAt + + expect(job.options.runAt).toEqual(runAt) + }) +}) + +describe('set runAt()', () => { + test('can set the runAt time directly on the instance', () => { + const futureDate = new Date(2030, 1, 2, 12, 34, 56) + const job = new RedwoodJob() + job.runAt = futureDate + + expect(job.runAt).toEqual(futureDate) + }) + + test('sets the `options.runAt` property', () => { + const futureDate = new Date(2030, 1, 2, 12, 34, 56) + const job = new RedwoodJob() + job.runAt = futureDate + + expect(job.options.runAt).toEqual(futureDate) + }) +}) + +describe('get queue()', () => { + test('defaults to queue set in class', () => { + const job = new RedwoodJob() + + expect(job.queue).toEqual(RedwoodJob.queue) + }) + + test('can manually set the queue name on an instance', () => { + const job = new RedwoodJob() + job.queue = 'priority' + + expect(job.queue).toEqual('priority') + }) + + test('queue set manually overrides queue set as an option', () => { + const job = RedwoodJob.set({ queue: 'priority' }) + job.queue = 'important' + + expect(job.queue).toEqual('important') + }) +}) + +describe('set queue()', () => { + test('sets the queue name in `options.queue`', () => { + const job = new RedwoodJob() + job.queue = 'priority' + + expect(job.options.queue).toEqual('priority') + }) +}) + +describe('get priority()', () => { + test('defaults to priority set in class', () => { + const job = new RedwoodJob() + + expect(job.priority).toEqual(RedwoodJob.priority) + }) + + test('can manually set the priority name on an instance', () => { + const job = new RedwoodJob() + job.priority = 10 + + expect(job.priority).toEqual(10) + }) + + test('priority set manually overrides priority set as an option', () => { + const job = RedwoodJob.set({ priority: 20 }) + job.priority = 10 + + expect(job.priority).toEqual(10) + }) +}) + +describe('set priority()', () => { + test('sets the priority in `options.priority`', () => { + const job = new RedwoodJob() + job.priority = 10 + + expect(job.options.priority).toEqual(10) + }) +}) + +describe('static performLater()', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('invokes the instance performLater()', () => { + class TestJob extends RedwoodJob { + async perform() { + return 'done' + } + } + const spy = jest.spyOn(TestJob.prototype, 'performLater') + const mockAdapter = { schedule: jest.fn() } + RedwoodJob.config({ adapter: mockAdapter }) + + TestJob.performLater('foo', 'bar') + + expect(spy).toHaveBeenCalledWith('foo', 'bar') + }) +}) + +describe('instance performLater()', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('throws an error if no adapter is configured', async () => { + RedwoodJob.config({ adapter: undefined }) + + const job = new RedwoodJob() + + await expect(job.performLater('foo', 'bar')).rejects.toThrow( + errors.AdapterNotConfiguredError, + ) + }) + + test('logs that the job is being scheduled', async () => { + class TestJob extends RedwoodJob { + async perform() { + return 'done' + } + } + const mockAdapter = { schedule: jest.fn() } + const mockLogger = { info: jest.fn() } + RedwoodJob.config({ adapter: mockAdapter, logger: mockLogger }) + const spy = jest.spyOn(mockLogger, 'info') + + await new TestJob().performLater('foo', 'bar') + + expect(spy).toHaveBeenCalledWith( + { + args: ['foo', 'bar'], + handler: 'TestJob', + priority: 50, + queue: 'default', + runAt: new Date(), + }, + '[RedwoodJob] Scheduling TestJob', + ) + }) + + test('calls the `schedule` function on the adapter', async () => { + class TestJob extends RedwoodJob { + async perform() { + return 'done' + } + } + const mockAdapter = { schedule: jest.fn() } + RedwoodJob.config({ adapter: mockAdapter }) + const spy = jest.spyOn(mockAdapter, 'schedule') + + await new TestJob().performLater('foo', 'bar') + + expect(spy).toHaveBeenCalledWith({ + handler: 'TestJob', + args: ['foo', 'bar'], + queue: 'default', + priority: 50, + runAt: new Date(), + }) + }) + + test('returns whatever the adapter returns', async () => { + class TestJob extends RedwoodJob { + async perform() { + return 'done' + } + } + const scheduleReturn = { status: 'scheduled' } + const mockAdapter = { + schedule: jest.fn(() => scheduleReturn), + } + RedwoodJob.config({ adapter: mockAdapter }) + + const result = await new TestJob().performLater('foo', 'bar') + + expect(result).toEqual(scheduleReturn) + }) + + test('catches any errors thrown during schedulding and throws custom error', async () => { + class TestJob extends RedwoodJob { + async perform() { + return 'done' + } + } + const mockAdapter = { + schedule: jest.fn(() => { + throw new Error('Could not schedule') + }), + } + RedwoodJob.config({ adapter: mockAdapter }) + + try { + await new TestJob().performLater('foo', 'bar') + } catch (e) { + expect(e).toBeInstanceOf(errors.SchedulingError) + expect(e.message).toEqual( + '[RedwoodJob] Exception when scheduling TestJob', + ) + expect(e.original_error.message).toEqual('Could not schedule') + } + }) +}) + +describe('static performNow()', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('invokes the instance performNow()', () => { + class TestJob extends RedwoodJob { + async perform() { + return 'done' + } + } + const spy = jest.spyOn(TestJob.prototype, 'performNow') + const mockAdapter = { schedule: jest.fn() } + RedwoodJob.config({ adapter: mockAdapter }) + + TestJob.performNow('foo', 'bar') + + expect(spy).toHaveBeenCalledWith('foo', 'bar') + }) +}) + +describe('instance performNow()', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('throws an error if perform() function is not implemented', async () => { + class TestJob extends RedwoodJob {} + const job = new TestJob() + + await expect(job.performNow('foo', 'bar')).rejects.toThrow( + errors.PerformNotImplementedError, + ) + }) + + test('logs that the job is being run', async () => { + class TestJob extends RedwoodJob { + async perform() { + return 'done' + } + } + const mockAdapter = { schedule: jest.fn() } + const mockLogger = { info: jest.fn() } + RedwoodJob.config({ adapter: mockAdapter, logger: mockLogger }) + const spy = jest.spyOn(mockLogger, 'info') + + await new TestJob().performNow('foo', 'bar') + + expect(spy).toHaveBeenCalledWith( + { + args: ['foo', 'bar'], + handler: 'TestJob', + priority: 50, + queue: 'default', + runAt: new Date(), + }, + '[RedwoodJob] Running TestJob now', + ) + }) + + test('invokes the perform() function immediately', async () => { + class TestJob extends RedwoodJob { + async perform() { + return 'done' + } + } + + const spy = jest.spyOn(TestJob.prototype, 'perform') + + await new TestJob().performNow('foo', 'bar') + + expect(spy).toHaveBeenCalledWith('foo', 'bar') + }) + + test('returns whatever the perform() function returns', async () => { + const performReturn = { status: 'done' } + class TestJob extends RedwoodJob { + async perform() { + return performReturn + } + } + + const result = await new TestJob().performNow('foo', 'bar') + + expect(result).toEqual(performReturn) + }) + + test('catches any errors thrown during perform and throws custom error', async () => { + class TestJob extends RedwoodJob { + async perform() { + throw new Error('Could not perform') + } + } + const mockAdapter = { + schedule: jest.fn(() => { + throw new Error('Could not schedule') + }), + } + RedwoodJob.config({ adapter: mockAdapter }) + + try { + await new TestJob().performNow('foo', 'bar') + } catch (e) { + expect(e).toBeInstanceOf(errors.PerformError) + expect(e.message).toEqual('[TestJob] exception when running job') + expect(e.original_error.message).toEqual('Could not perform') + } + }) +}) + +describe('perform()', () => { + test('throws an error if not implemented', () => { + const job = new RedwoodJob() + + expect(() => job.perform()).toThrow(errors.PerformNotImplementedError) + }) +}) + +describe('subclasses', () => { + test('can set their own default queue', () => { + class MailerJob extends RedwoodJob { + static queue = 'mailers' + } + + // class access + expect(MailerJob.queue).toEqual('mailers') + expect(RedwoodJob.queue).toEqual('default') + + // instance access + const mailerJob = new MailerJob() + const redwoodJob = new RedwoodJob() + expect(mailerJob.queue).toEqual('mailers') + expect(redwoodJob.queue).toEqual('default') + }) + + test('can set their own default priority', () => { + class PriorityJob extends RedwoodJob { + static priority = 10 + } + + // class access + expect(PriorityJob.priority).toEqual(10) + expect(RedwoodJob.priority).toEqual(50) + + // instance access + const priorityJob = new PriorityJob() + const redwoodJob = new RedwoodJob() + expect(priorityJob.priority).toEqual(10) + expect(redwoodJob.priority).toEqual(50) + }) +}) diff --git a/packages/jobs/src/core/__tests__/Worker.test.js b/packages/jobs/src/core/__tests__/Worker.test.js new file mode 100644 index 000000000000..627e76aa7ab6 --- /dev/null +++ b/packages/jobs/src/core/__tests__/Worker.test.js @@ -0,0 +1,197 @@ +import * as errors from '../../core/errors' +import { Executor } from '../Executor' +import { Worker, DEFAULT_MAX_RUNTIME, DEFAULT_WAIT_TIME } from '../Worker' + +jest.mock('../Executor') + +jest.useFakeTimers().setSystemTime(new Date('2024-01-01')) + +describe('constructor', () => { + test('saves options', () => { + const options = { adapter: 'adapter' } + const worker = new Worker(options) + + expect(worker.options).toEqual(options) + }) + + test('extracts adaptert from options to variable', () => { + const options = { adapter: 'adapter' } + const worker = new Worker(options) + + expect(worker.adapter).toEqual('adapter') + }) + + test('extracts queue from options to variable', () => { + const options = { adapter: 'adapter', queue: 'queue' } + const worker = new Worker(options) + + expect(worker.queue).toEqual('queue') + }) + + test('queue will be null if no queue specified', () => { + const options = { adapter: 'adapter' } + const worker = new Worker(options) + + expect(worker.queue).toBeNull() + }) + + test('extracts processName from options to variable', () => { + const options = { adapter: 'adapter', processName: 'processName' } + const worker = new Worker(options) + + expect(worker.processName).toEqual('processName') + }) + + test('defaults processName if not provided', () => { + const options = { adapter: 'adapter' } + const worker = new Worker(options) + + expect(worker.processName).not.toBeUndefined() + }) + + test('extracts maxRuntime from options to variable', () => { + const options = { adapter: 'adapter', maxRuntime: 1000 } + const worker = new Worker(options) + + expect(worker.maxRuntime).toEqual(1000) + }) + + test('sets default maxRuntime if not provided', () => { + const options = { adapter: 'adapter' } + const worker = new Worker(options) + + expect(worker.maxRuntime).toEqual(DEFAULT_MAX_RUNTIME) + }) + + test('extracts waitTime from options to variable', () => { + const options = { adapter: 'adapter', waitTime: 1000 } + const worker = new Worker(options) + + expect(worker.waitTime).toEqual(1000) + }) + + test('sets default waitTime if not provided', () => { + const options = { adapter: 'adapter' } + const worker = new Worker(options) + + expect(worker.waitTime).toEqual(DEFAULT_WAIT_TIME) + }) + + test('can set waitTime to 0', () => { + const options = { adapter: 'adapter', waitTime: 0 } + const worker = new Worker(options) + + expect(worker.waitTime).toEqual(0) + }) + + test('sets lastCheckTime to the current time', () => { + const options = { adapter: 'adapter' } + const worker = new Worker(options) + + expect(worker.lastCheckTime).toBeInstanceOf(Date) + }) + + test('extracts forever from options to variable', () => { + const options = { adapter: 'adapter', forever: false } + const worker = new Worker(options) + + expect(worker.forever).toEqual(false) + }) + + test('sets forever to `true` by default', () => { + const options = { adapter: 'adapter' } + const worker = new Worker(options) + + expect(worker.forever).toEqual(true) + }) + + test('throws an error if adapter not set', () => { + expect(() => new Worker()).toThrow(errors.AdapterRequiredError) + }) +}) + +const originalConsoleDebug = console.debug + +describe('run', () => { + beforeAll(() => { + // hide console.debug output during test run + console.debug = jest.fn() + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + afterAll(() => { + // reenable console.debug output during test run + console.debug = originalConsoleDebug + }) + + test('tries to find a job', async () => { + const adapter = { find: jest.fn(() => null) } + const worker = new Worker({ adapter, waitTime: 0, forever: false }) + + await worker.run() + + expect(adapter.find).toHaveBeenCalledWith({ + processName: worker.processName, + maxRuntime: worker.maxRuntime, + queue: worker.queue, + }) + }) + + test('does nothing if no job found and forever=false', async () => { + const adapter = { find: jest.fn(() => null) } + const mockExecutor = jest.fn() + jest.mock('../Executor', () => ({ Executor: mockExecutor })) + + const worker = new Worker({ adapter, waitTime: 0, forever: false }) + await worker.run() + + expect(mockExecutor).not.toHaveBeenCalled() + }) + + test('does nothing if no job found and workoff=true', async () => { + const adapter = { find: jest.fn(() => null) } + const mockExecutor = jest.fn() + jest.mock('../Executor', () => ({ Executor: mockExecutor })) + + const worker = new Worker({ adapter, waitTime: 0, workoff: true }) + await worker.run() + + expect(mockExecutor).not.toHaveBeenCalled() + }) + + test('initializes an Executor instance if the job is found', async () => { + const adapter = { find: jest.fn(() => ({ id: 1 })) } + const worker = new Worker({ adapter, waitTime: 0, forever: false }) + + await worker.run() + + expect(Executor).toHaveBeenCalledWith({ + adapter, + job: { id: 1 }, + logger: worker.logger, + }) + }) + + test('calls `perform` on the Executor instance', async () => { + const adapter = { find: jest.fn(() => ({ id: 1 })) } + const spy = jest.spyOn(Executor.prototype, 'perform') + const worker = new Worker({ adapter, waitTime: 0, forever: false }) + + await worker.run() + + expect(spy).toHaveBeenCalled() + }) + + test('calls `perform` on the Executor instance', async () => { + const adapter = { find: jest.fn(() => ({ id: 1 })) } + const spy = jest.spyOn(Executor.prototype, 'perform') + const worker = new Worker({ adapter, waitTime: 0, forever: false }) + + await worker.run() + + expect(spy).toHaveBeenCalled() + }) +}) diff --git a/packages/jobs/src/core/errors.js b/packages/jobs/src/core/errors.js new file mode 100644 index 000000000000..a5e7b5571e21 --- /dev/null +++ b/packages/jobs/src/core/errors.js @@ -0,0 +1,99 @@ +// Parent class for any RedwoodJob-related error +export class RedwoodJobError extends Error { + constructor(message) { + super(message) + this.name = this.constructor.name + } +} + +// Thrown when trying to schedule a job without an adapter configured +export class AdapterNotConfiguredError extends RedwoodJobError { + constructor() { + super('No adapter configured for RedwoodJob') + } +} + +// Thrown when trying to schedule a job without a `perform` method +export class PerformNotImplementedError extends RedwoodJobError { + constructor() { + super('You must implement the `perform` method in your job class') + } +} + +// Thrown when a custom adapter does not implement the `schedule` method +export class NotImplementedError extends RedwoodJobError { + constructor(name) { + super(`You must implement the \`${name}\` method in your adapter`) + } +} + +export class ModelNameError extends RedwoodJobError { + constructor(name) { + super(`Model \`${name}\` not found in PrismaClient`) + } +} + +// Parent class for any job where we want to wrap the underlying error in our +// own. Use by extending this class and passing the original error to the +// constructor: +// +// try { +// throw new Error('Generic error') +// } catch (e) { +// throw new RethrowJobError('Custom Error Message', e) +// } +export class RethrownJobError extends RedwoodJobError { + constructor(message, error) { + super(message) + + if (!error) { + throw new Error( + 'RethrownJobError requires a message and existing error object', + ) + } + + this.original_error = error + this.stack_before_rethrow = this.stack + + const messageLines = (this.message.match(/\n/g) || []).length + 1 + this.stack = + this.stack + .split('\n') + .slice(0, messageLines + 1) + .join('\n') + + '\n' + + error.stack + } +} + +// Thrown when there is an error scheduling a job, wraps the underlying error +export class SchedulingError extends RethrownJobError { + constructor(message, error) { + super(message, error) + } +} + +// Thrown when there is an error performing a job, wraps the underlying error +export class PerformError extends RethrownJobError { + constructor(message, error) { + super(message, error) + } +} + +export class AdapterRequiredError extends RedwoodJobError { + constructor() { + super('`adapter` is required to perform a job') + } +} + +export class JobRequiredError extends RedwoodJobError { + constructor() { + super('`job` is required to perform a job') + } +} + +export class JobNotFoundError extends RedwoodJobError { + constructor(name) { + super(`Job \`${name}\` not found in the filesystem`) + } +} diff --git a/packages/jobs/src/index.ts b/packages/jobs/src/index.ts new file mode 100644 index 000000000000..54149c361b60 --- /dev/null +++ b/packages/jobs/src/index.ts @@ -0,0 +1,8 @@ +export { RedwoodJob } from './core/RedwoodJob' +export { Executor } from './core/Executor' +export { Worker } from './core/Worker' + +export * from './core/errors' + +export { BaseAdapter } from './adapters/BaseAdapter' +export { PrismaAdapter } from './adapters/PrismaAdapter' diff --git a/packages/jobs/tsconfig.json b/packages/jobs/tsconfig.json new file mode 100644 index 000000000000..1259c1abbf06 --- /dev/null +++ b/packages/jobs/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.compilerOption.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "allowJs": true + }, + "include": ["src/**/*"] +} diff --git a/yarn.lock b/yarn.lock index 8ae06f6cb0d0..6f0bbd3fe067 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8315,6 +8315,20 @@ __metadata: languageName: unknown linkType: soft +"@redwoodjs/jobs@workspace:packages/jobs": + version: 0.0.0-use.local + resolution: "@redwoodjs/jobs@workspace:packages/jobs" + dependencies: + "@babel/runtime-corejs3": "npm:7.24.5" + "@redwoodjs/project-config": "workspace:*" + core-js: "npm:3.37.1" + fast-glob: "npm:3.3.2" + jest: "npm:29.7.0" + tsx: "npm:4.15.6" + typescript: "npm:5.4.5" + languageName: unknown + linkType: soft + "@redwoodjs/mailer-core@workspace:*, @redwoodjs/mailer-core@workspace:packages/mailer/core": version: 0.0.0-use.local resolution: "@redwoodjs/mailer-core@workspace:packages/mailer/core" From 16deb0ac5f8a97ea4ef6f8ef2f6f02f66a4e54da Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 26 Jun 2024 16:28:40 -0700 Subject: [PATCH 002/258] Hardcode expected time --- packages/jobs/src/core/__tests__/RedwoodJob.test.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/jobs/src/core/__tests__/RedwoodJob.test.js b/packages/jobs/src/core/__tests__/RedwoodJob.test.js index fb0a320fbc10..e8f4b10713cd 100644 --- a/packages/jobs/src/core/__tests__/RedwoodJob.test.js +++ b/packages/jobs/src/core/__tests__/RedwoodJob.test.js @@ -135,12 +135,10 @@ describe('get runAt()', () => { expect(job.runAt).toEqual(new Date()) }) - test.only('returns a datetime `wait` seconds in the future if option set', async () => { + test('returns a datetime `wait` seconds in the future if option set', async () => { const job = RedwoodJob.set({ wait: 300 }) - console.info(job.runAt) - - expect(job.runAt).toEqual(new Date(new Date().getTime() + 300 * 1000)) + expect(job.runAt).toEqual(new Date(Date.UTC(2024, 0, 1, 0, 5, 0))) }) test('returns a datetime set to `waitUntil` if option set', async () => { From b72921cd1d150bc16adfeb42318b7f69a5691e51 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 26 Jun 2024 20:25:19 -0700 Subject: [PATCH 003/258] Additional errors --- packages/jobs/src/core/errors.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/jobs/src/core/errors.js b/packages/jobs/src/core/errors.js index a5e7b5571e21..df0d130630a0 100644 --- a/packages/jobs/src/core/errors.js +++ b/packages/jobs/src/core/errors.js @@ -80,20 +80,42 @@ export class PerformError extends RethrownJobError { } } +// Thrown when the Executor is instantiated without an adapter export class AdapterRequiredError extends RedwoodJobError { constructor() { super('`adapter` is required to perform a job') } } +// Thrown when the Executor is instantiated without a job export class JobRequiredError extends RedwoodJobError { constructor() { super('`job` is required to perform a job') } } +// Throw when a job with the given handler is not found in the filesystem export class JobNotFoundError extends RedwoodJobError { constructor(name) { super(`Job \`${name}\` not found in the filesystem`) } } + +// Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js and +// the file does not exist +export class JobsLibNotFoundError extends RedwoodJobError { + constructor() { + super( + 'api/src/lib/jobs.js not found. Create this file and export `adapter` for the job runner to use', + ) + } +} + +// Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js +export class AdapterNotFoundError extends RedwoodJobError { + constructor() { + super( + 'api/src/lib/jobs.js does not export `adapter`. Create this file and export `adapter` for the job runner to use', + ) + } +} From 2421f653cb0d9d3c27f3a7a88d98cab5181700f8 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 26 Jun 2024 20:25:29 -0700 Subject: [PATCH 004/258] Rename tests --- .../jobs/src/bins/__tests__/{runner.test.mjs => runner.test.js} | 0 .../jobs/src/bins/__tests__/{worker.test.mjs => worker.test.js} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/jobs/src/bins/__tests__/{runner.test.mjs => runner.test.js} (100%) rename packages/jobs/src/bins/__tests__/{worker.test.mjs => worker.test.js} (100%) diff --git a/packages/jobs/src/bins/__tests__/runner.test.mjs b/packages/jobs/src/bins/__tests__/runner.test.js similarity index 100% rename from packages/jobs/src/bins/__tests__/runner.test.mjs rename to packages/jobs/src/bins/__tests__/runner.test.js diff --git a/packages/jobs/src/bins/__tests__/worker.test.mjs b/packages/jobs/src/bins/__tests__/worker.test.js similarity index 100% rename from packages/jobs/src/bins/__tests__/worker.test.mjs rename to packages/jobs/src/bins/__tests__/worker.test.js From 43cbdd0682f9ec653046eaef639dcba56116e307 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 26 Jun 2024 20:25:53 -0700 Subject: [PATCH 005/258] Start trying to load logger and adapter from app filesystem --- packages/jobs/src/bins/runner.js | 60 ++++++++++++++++++++------------ packages/jobs/src/bins/shared.js | 40 +++++++++++++++++++++ packages/jobs/src/bins/worker.js | 21 +++++++---- 3 files changed, 92 insertions(+), 29 deletions(-) create mode 100644 packages/jobs/src/bins/shared.js diff --git a/packages/jobs/src/bins/runner.js b/packages/jobs/src/bins/runner.js index 248c51938d22..636c091bad4c 100755 --- a/packages/jobs/src/bins/runner.js +++ b/packages/jobs/src/bins/runner.js @@ -4,13 +4,15 @@ // detaching in [start] mode. import { fork, exec } from 'node:child_process' +import path from 'node:path' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' import { loadEnvFiles } from '@redwoodjs/cli/dist/lib/loadEnvFiles' -import { logger } from './lib/logger.js' +import { loadLogger } from './shared' + loadEnvFiles() process.title = 'rw-job-runner' @@ -18,7 +20,7 @@ process.title = 'rw-job-runner' const parseArgs = (argv) => { const parsed = yargs(hideBin(argv)) .usage( - 'Starts the RedwoodJob runner to process background jobs\n\nUsage: $0 [options]' + 'Starts the RedwoodJob runner to process background jobs\n\nUsage: $0 [options]', ) .command('work', 'Start a worker and process jobs') .command('workoff', 'Start a worker and exit after all jobs processed') @@ -32,11 +34,11 @@ const parseArgs = (argv) => { }) .example( '$0 start -n 2', - 'Start the job runner with 2 workers in daemon mode' + 'Start the job runner with 2 workers in daemon mode', ) .example( '$0 start -n default:2,email:1', - 'Start the job runner in daemon mode with 2 workers for the "default" queue and 1 for the "email" queue' + 'Start the job runner in daemon mode with 2 workers for the "default" queue and 1 for the "email" queue', ) }) .command('stop', 'Stop any daemonized job workers') @@ -53,23 +55,23 @@ const parseArgs = (argv) => { }) .example( '$0 restart -n 2', - 'Restart the job runner with 2 workers in daemon mode' + 'Restart the job runner with 2 workers in daemon mode', ) .example( '$0 restart -n default:2,email:1', - 'Restart the job runner in daemon mode with 2 workers for the `default` queue and 1 for the `email` queue' + 'Restart the job runner in daemon mode with 2 workers for the `default` queue and 1 for the `email` queue', ) - } + }, ) .command('clear', 'Clear the job queue') .demandCommand(1, 'You must specify a mode to start in') .example( '$0 start -n 2', - 'Start the job runner with 2 workers in daemon mode' + 'Start the job runner with 2 workers in daemon mode', ) .example( '$0 start -n default:2,email:1', - 'Start the job runner in daemon mode with 2 workers for the "default" queue and 1 for the "email" queue' + 'Start the job runner in daemon mode with 2 workers for the "default" queue and 1 for the "email" queue', ) .help().argv @@ -104,7 +106,12 @@ const buildWorkerConfig = (numWorkers) => { return workers } -const startWorkers = ({ workerConfig, detach = false, workoff = false }) => { +const startWorkers = ({ + workerConfig, + detach = false, + workoff = false, + logger, +}) => { logger.warn(`Starting ${workerConfig.length} worker(s)...`) return workerConfig.map(([queue, id], i) => { @@ -122,7 +129,7 @@ const startWorkers = ({ workerConfig, detach = false, workoff = false }) => { } // fork the worker process - const worker = fork('api/dist/worker.js', workerArgs, { + const worker = fork(path.join(__dirname, 'worker.js'), workerArgs, { detached: detach, stdio: detach ? 'ignore' : 'inherit', }) @@ -138,7 +145,7 @@ const startWorkers = ({ workerConfig, detach = false, workoff = false }) => { }) } -const signalSetup = (workers) => { +const signalSetup = ({ workers, logger }) => { // if we get here then we're still monitoring workers and have to pass on signals let sigtermCount = 0 @@ -197,9 +204,9 @@ const findProcessId = async (proc) => { } // TODO add support for stopping with SIGTERM or SIGKILL? -const stopWorkers = async ({ workerConfig, signal = 'SIGINT' }) => { +const stopWorkers = async ({ workerConfig, signal = 'SIGINT', logger }) => { logger.warn( - `Stopping ${workerConfig.length} worker(s) gracefully (${signal})...` + `Stopping ${workerConfig.length} worker(s) gracefully (${signal})...`, ) for (const [queue, id] of workerConfig) { @@ -212,7 +219,7 @@ const stopWorkers = async ({ workerConfig, signal = 'SIGINT' }) => { } logger.info( - `Stopping worker ${workerTitle} with process id ${processId}...` + `Stopping worker ${workerTitle} with process id ${processId}...`, ) process.kill(processId, signal) @@ -223,31 +230,38 @@ const stopWorkers = async ({ workerConfig, signal = 'SIGINT' }) => { } } -const clearQueue = () => { +const clearQueue = ({ logger }) => { logger.warn(`Starting worker to clear job queue...`) - fork('api/dist/worker.js', ['--clear']) + fork(path.join(__dirname, 'worker.js'), ['--clear']) } const main = async () => { const { numWorkers, command } = parseArgs(process.argv) const workerConfig = buildWorkerConfig(numWorkers) + const logger = (await loadLogger()) || console logger.warn(`Starting RedwoodJob Runner at ${new Date().toISOString()}...`) switch (command) { case 'start': - startWorkers({ workerConfig, detach: true }) + startWorkers({ workerConfig, detach: true, logger }) return process.exit(0) case 'restart': - await stopWorkers({ workerConfig, signal: 2 }) - startWorkers({ workerConfig, detach: true }) + await stopWorkers({ workerConfig, signal: 2, logger }) + startWorkers({ workerConfig, detach: true, logger }) return process.exit(0) case 'work': - return signalSetup(startWorkers({ workerConfig })) + return signalSetup({ + workers: startWorkers({ workerConfig, logger }), + logger, + }) case 'workoff': - return signalSetup(startWorkers({ workerConfig, workoff: true })) + return signalSetup({ + workers: startWorkers({ workerConfig, workoff: true, logger }), + logger, + }) case 'stop': - return await stopWorkers({ workerConfig, signal: 'SIGINT' }) + return await stopWorkers({ workerConfig, signal: 'SIGINT', logger }) case 'clear': return clearQueue() } diff --git a/packages/jobs/src/bins/shared.js b/packages/jobs/src/bins/shared.js new file mode 100644 index 000000000000..a27aced8bc39 --- /dev/null +++ b/packages/jobs/src/bins/shared.js @@ -0,0 +1,40 @@ +import path from 'node:path' + +import fg from 'fast-glob' + +import { getPaths } from '@redwoodjs/project-config' + +import { AdapterNotFoundError, JobsLibNotFoundError } from '../core/errors' + +export const loadAdapter = async (logger) => { + const files = fg.sync('jobs.*', { cwd: getPaths().api.lib }) + + if (files.length) { + try { + const loggerModule = await import(path.join(getPaths().api.lib, files[0])) + return loggerModule.adapter + } catch (e) { + // api/src/lib/jobs.js doesn't exist or doesn't export `adapter` + throw new AdapterNotFoundError() + } + } else { + throw new JobsLibNotFoundError() + } + + return null +} + +export const loadLogger = async () => { + const files = fg.sync('logger.*', { cwd: getPaths().api.lib }) + + if (files.length) { + // try { + const loggerModule = await import(path.join(getPaths().api.lib, files[0])) + return loggerModule.logger + // } catch (e) { + // import didn't work for whatever reason, fall back to console + // } + } + + return null +} diff --git a/packages/jobs/src/bins/worker.js b/packages/jobs/src/bins/worker.js index 064f93eda6fa..ac9893f57616 100755 --- a/packages/jobs/src/bins/worker.js +++ b/packages/jobs/src/bins/worker.js @@ -6,13 +6,11 @@ import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' import { loadEnvFiles } from '@redwoodjs/cli/dist/lib/loadEnvFiles' -loadEnvFiles() +loadEnvFiles() import { Worker } from '../core/Worker' -// TODO import from app somehow? -import { adapter } from './lib/jobs' -import { logger } from './lib/logger.js' +import { loadAdapter, loadLogger } from './shared' const TITLE_PREFIX = `rw-job-worker` @@ -58,7 +56,7 @@ const setProcessTitle = ({ id, queue }) => { process.title = title } -const setupSignals = (worker) => { +const setupSignals = ({ worker, logger }) => { // if the parent itself receives a ctrl-c it'll pass that to the workers. // workers will exit gracefully by setting `forever` to `false` which will tell // it not to pick up a new job when done with the current one @@ -86,6 +84,17 @@ const main = async () => { const { id, queue, clear, workoff } = parseArgs(process.argv) setProcessTitle({ id, queue }) + const logger = await loadLogger() + let adapter + + try { + adapter = await loadAdapter() + } catch (e) { + // TODO check for file not existing vs not exporting `adapter` + logger.error(e) + process.exit(1) + } + logger.info( { worker: process.title }, `Starting work at ${new Date().toISOString()}...`, @@ -105,7 +114,7 @@ const main = async () => { process.exit(0) }) - setupSignals(worker) + setupSignals({ worker, logger }) } main() From ef533dc67afdfd80779e4ade1a8cbcd9cc05bf6d Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 26 Jun 2024 20:36:23 -0700 Subject: [PATCH 006/258] Import console and process --- packages/jobs/src/bins/runner.js | 3 ++- packages/jobs/src/bins/shared.js | 5 ++--- packages/jobs/src/bins/worker.js | 4 +++- packages/jobs/src/core/Executor.js | 2 ++ packages/jobs/src/core/Worker.js | 3 +++ 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/jobs/src/bins/runner.js b/packages/jobs/src/bins/runner.js index 636c091bad4c..d8ac98b4e5e6 100755 --- a/packages/jobs/src/bins/runner.js +++ b/packages/jobs/src/bins/runner.js @@ -5,6 +5,7 @@ import { fork, exec } from 'node:child_process' import path from 'node:path' +import process from 'node:process' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' @@ -238,7 +239,7 @@ const clearQueue = ({ logger }) => { const main = async () => { const { numWorkers, command } = parseArgs(process.argv) const workerConfig = buildWorkerConfig(numWorkers) - const logger = (await loadLogger()) || console + const logger = await loadLogger() logger.warn(`Starting RedwoodJob Runner at ${new Date().toISOString()}...`) diff --git a/packages/jobs/src/bins/shared.js b/packages/jobs/src/bins/shared.js index a27aced8bc39..d600ebede461 100644 --- a/packages/jobs/src/bins/shared.js +++ b/packages/jobs/src/bins/shared.js @@ -1,3 +1,4 @@ +import console from 'node:console' import path from 'node:path' import fg from 'fast-glob' @@ -20,8 +21,6 @@ export const loadAdapter = async (logger) => { } else { throw new JobsLibNotFoundError() } - - return null } export const loadLogger = async () => { @@ -36,5 +35,5 @@ export const loadLogger = async () => { // } } - return null + return console } diff --git a/packages/jobs/src/bins/worker.js b/packages/jobs/src/bins/worker.js index ac9893f57616..9bf97f7bec3c 100755 --- a/packages/jobs/src/bins/worker.js +++ b/packages/jobs/src/bins/worker.js @@ -2,6 +2,8 @@ // The process that actually starts an instance of Worker to process jobs. +import process from 'node:process' + import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' @@ -88,7 +90,7 @@ const main = async () => { let adapter try { - adapter = await loadAdapter() + adapter = await loadAdapter(logger) } catch (e) { // TODO check for file not existing vs not exporting `adapter` logger.error(e) diff --git a/packages/jobs/src/core/Executor.js b/packages/jobs/src/core/Executor.js index 241f258ce702..d9147a433f61 100644 --- a/packages/jobs/src/core/Executor.js +++ b/packages/jobs/src/core/Executor.js @@ -1,5 +1,7 @@ // Used by the job runner to execute a job and track success or failure +import console from 'node:console' + import fg from 'fast-glob' import { diff --git a/packages/jobs/src/core/Worker.js b/packages/jobs/src/core/Worker.js index dd3340012679..7f7a74974556 100644 --- a/packages/jobs/src/core/Worker.js +++ b/packages/jobs/src/core/Worker.js @@ -1,5 +1,8 @@ // Used by the job runner to find the next job to run and invoke the Executor +import console from 'node:console' +import process from 'node:process' + import { AdapterRequiredError } from './errors' import { Executor } from './Executor' From fd3a03de0fe0ef2e085572071fcfcc10071c23c4 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 27 Jun 2024 00:00:44 -0700 Subject: [PATCH 007/258] Use registerApiSideBabelHook() to get files ready for import from app to framework --- packages/jobs/src/bins/shared.js | 40 ++++++++++++++------------------ packages/jobs/src/bins/worker.js | 14 +++++------ 2 files changed, 25 insertions(+), 29 deletions(-) diff --git a/packages/jobs/src/bins/shared.js b/packages/jobs/src/bins/shared.js index d600ebede461..a46d82093377 100644 --- a/packages/jobs/src/bins/shared.js +++ b/packages/jobs/src/bins/shared.js @@ -3,37 +3,33 @@ import path from 'node:path' import fg from 'fast-glob' +import { registerApiSideBabelHook } from '@redwoodjs/babel-config' import { getPaths } from '@redwoodjs/project-config' import { AdapterNotFoundError, JobsLibNotFoundError } from '../core/errors' -export const loadAdapter = async (logger) => { - const files = fg.sync('jobs.*', { cwd: getPaths().api.lib }) +// TODO Don't use this in production, import from dist directly +registerApiSideBabelHook() - if (files.length) { - try { - const loggerModule = await import(path.join(getPaths().api.lib, files[0])) - return loggerModule.adapter - } catch (e) { - // api/src/lib/jobs.js doesn't exist or doesn't export `adapter` - throw new AdapterNotFoundError() - } +export const loadAdapter = async () => { + if (getPaths().api.jobs) { + // try { + const { default: jobsModule } = await import(getPaths().api.jobs) + return jobsModule.adapter + // } catch (e) { + // // api/src/lib/jobs.js doesn't exist or doesn't export `adapter` + // throw new AdapterNotFoundError() + // } } else { throw new JobsLibNotFoundError() } } export const loadLogger = async () => { - const files = fg.sync('logger.*', { cwd: getPaths().api.lib }) - - if (files.length) { - // try { - const loggerModule = await import(path.join(getPaths().api.lib, files[0])) - return loggerModule.logger - // } catch (e) { - // import didn't work for whatever reason, fall back to console - // } - } - - return console + // try { + const { default: loggerModule } = await import(getPaths().api.logger) + return loggerModule.logger + // } catch (e) { + // // import didn't work for whatever reason, fall back to console + // } } diff --git a/packages/jobs/src/bins/worker.js b/packages/jobs/src/bins/worker.js index 9bf97f7bec3c..715ecf162cf5 100755 --- a/packages/jobs/src/bins/worker.js +++ b/packages/jobs/src/bins/worker.js @@ -89,13 +89,13 @@ const main = async () => { const logger = await loadLogger() let adapter - try { - adapter = await loadAdapter(logger) - } catch (e) { - // TODO check for file not existing vs not exporting `adapter` - logger.error(e) - process.exit(1) - } + // try { + adapter = await loadAdapter(logger) + // } catch (e) { + // // TODO check for file not existing vs not exporting `adapter` + // logger.error(e) + // process.exit(1) + // } logger.info( { worker: process.title }, From 54002a906edfcd07f64a6d88d6d5829bda3354ac Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 27 Jun 2024 00:00:54 -0700 Subject: [PATCH 008/258] Add paths for jobs/logger --- packages/project-config/src/paths.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/project-config/src/paths.ts b/packages/project-config/src/paths.ts index 2111d6188291..70a1f19c19a2 100644 --- a/packages/project-config/src/paths.ts +++ b/packages/project-config/src/paths.ts @@ -199,7 +199,9 @@ export const getPaths = (BASE_DIR: string = getBaseDir()): Paths => { dbSchema: path.join(BASE_DIR, schemaPath), functions: path.join(BASE_DIR, PATH_API_DIR_FUNCTIONS), graphql: path.join(BASE_DIR, PATH_API_DIR_GRAPHQL), + jobs: resolveFile(path.join(BASE_DIR, PATH_API_DIR_LIB, 'jobs')), lib: path.join(BASE_DIR, PATH_API_DIR_LIB), + logger: resolveFile(path.join(BASE_DIR, PATH_API_DIR_LIB, 'logger')), generators: path.join(BASE_DIR, PATH_API_DIR_GENERATORS), config: path.join(BASE_DIR, PATH_API_DIR_CONFIG), services: path.join(BASE_DIR, PATH_API_DIR_SERVICES), @@ -207,6 +209,7 @@ export const getPaths = (BASE_DIR: string = getBaseDir()): Paths => { subscriptions: path.join(BASE_DIR, PATH_API_DIR_SUBSCRIPTIONS), src: path.join(BASE_DIR, PATH_API_DIR_SRC), dist: path.join(BASE_DIR, 'api/dist'), + // TODO Add dist paths for logger and jobs types: path.join(BASE_DIR, 'api/types'), models: path.join(BASE_DIR, PATH_API_DIR_MODELS), mail: path.join(BASE_DIR, PATH_API_DIR_SRC, 'mail'), From bb7e821027b9377b5c85712d22be4582d57dd314 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 27 Jun 2024 00:01:12 -0700 Subject: [PATCH 009/258] Convert tests over to vitest --- .../adapters/__tests__/BaseAdapter.test.js | 4 +- .../adapters/__tests__/PrismaAdapter.test.js | 10 ++-- .../jobs/src/core/__tests__/Executor.test.js | 9 ++-- .../src/core/__tests__/RedwoodJob.test.js | 48 ++++++++++--------- .../jobs/src/core/__tests__/Worker.test.js | 42 +++++++++------- packages/jobs/vitest.config.mjs | 9 ++++ 6 files changed, 75 insertions(+), 47 deletions(-) create mode 100644 packages/jobs/vitest.config.mjs diff --git a/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js b/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js index 7fce0b6fbbb9..2fc37d8f9903 100644 --- a/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js +++ b/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js @@ -1,3 +1,5 @@ +import { describe, expect, vi, test } from 'vitest' + import * as errors from '../../core/errors' import { BaseAdapter } from '../BaseAdapter' @@ -9,7 +11,7 @@ describe('constructor', () => { }) test('creates a separate instance var for any logger', () => { - const mockLogger = jest.fn() + const mockLogger = vi.fn() const adapter = new BaseAdapter({ foo: 'bar', logger: mockLogger }) expect(adapter.logger).toEqual(mockLogger) diff --git a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js index e4e2f07a7c45..4efd09f44cf8 100644 --- a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js +++ b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js @@ -1,3 +1,5 @@ +import { describe, expect, vi, test, beforeEach, afterEach } from 'vitest' + import { db } from 'src/lib/db' import * as errors from '../../core/errors' @@ -7,11 +9,11 @@ import { DEFAULT_MAX_ATTEMPTS, } from '../PrismaAdapter' -jest.useFakeTimers().setSystemTime(new Date('2024-01-01')) +vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) describe('constructor', () => { beforeEach(() => { - jest.clearAllMocks() + vi.clearAllMocks() }) test('defaults this.model name', () => { @@ -21,7 +23,7 @@ describe('constructor', () => { }) test('can manually set this.model', () => { - const dbMock = jest.fn(() => ({ + const dbMock = vi.fn(() => ({ _runtimeDataModel: { models: { Job: { @@ -58,7 +60,7 @@ describe('constructor', () => { }) test('set this.tableName from custom @@map() name in schema', () => { - const dbMock = jest.fn(() => ({ + const dbMock = vi.fn(() => ({ _runtimeDataModel: { models: { BackgroundJob: { diff --git a/packages/jobs/src/core/__tests__/Executor.test.js b/packages/jobs/src/core/__tests__/Executor.test.js index bc703a31ec75..8bf76ba34fed 100644 --- a/packages/jobs/src/core/__tests__/Executor.test.js +++ b/packages/jobs/src/core/__tests__/Executor.test.js @@ -1,3 +1,5 @@ +import { describe, expect, vi, test } from 'vitest' + import * as errors from '../../core/errors' import { Executor } from '../Executor' @@ -37,6 +39,7 @@ describe('constructor', () => { }) describe('perform', () => { + // TODO once these dynamic imports are converted into loadJob in shared, just mock out the result of loadJob test.skip('invokes the `perform` method on the job class', async () => { const options = { adapter: 'adapter', @@ -45,10 +48,10 @@ describe('perform', () => { const executor = new Executor(options) const job = { id: 1 } - const mockJob = jest.fn(() => { - return { perform: jest.fn() } + const mockJob = vi.fn(() => { + return { perform: vi.fn() } }) - jest.mock(`../Foo`, () => ({ Foo: mockJob }), { virtual: true }) + vi.mock(`../Foo`, () => ({ Foo: mockJob }), { virtual: true }) await executor.perform(job) diff --git a/packages/jobs/src/core/__tests__/RedwoodJob.test.js b/packages/jobs/src/core/__tests__/RedwoodJob.test.js index e8f4b10713cd..16cac1d47fa6 100644 --- a/packages/jobs/src/core/__tests__/RedwoodJob.test.js +++ b/packages/jobs/src/core/__tests__/RedwoodJob.test.js @@ -1,11 +1,13 @@ +import { describe, expect, vi, test, beforeEach } from 'vitest' + import * as errors from '../../core/errors' import { RedwoodJob } from '../RedwoodJob' -jest.useFakeTimers().setSystemTime(new Date('2024-01-01')) +vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) describe('static config', () => { test('can set the adapter', () => { - const adapter = { schedule: jest.fn() } + const adapter = { schedule: vi.fn() } RedwoodJob.config({ adapter }) @@ -13,7 +15,7 @@ describe('static config', () => { }) test('can set the logger', () => { - const logger = { info: jest.fn() } + const logger = { info: vi.fn() } RedwoodJob.config({ logger }) @@ -248,7 +250,7 @@ describe('set priority()', () => { describe('static performLater()', () => { beforeEach(() => { - jest.clearAllMocks() + vi.clearAllMocks() }) test('invokes the instance performLater()', () => { @@ -257,8 +259,8 @@ describe('static performLater()', () => { return 'done' } } - const spy = jest.spyOn(TestJob.prototype, 'performLater') - const mockAdapter = { schedule: jest.fn() } + const spy = vi.spyOn(TestJob.prototype, 'performLater') + const mockAdapter = { schedule: vi.fn() } RedwoodJob.config({ adapter: mockAdapter }) TestJob.performLater('foo', 'bar') @@ -269,7 +271,7 @@ describe('static performLater()', () => { describe('instance performLater()', () => { beforeEach(() => { - jest.clearAllMocks() + vi.clearAllMocks() }) test('throws an error if no adapter is configured', async () => { @@ -288,10 +290,10 @@ describe('instance performLater()', () => { return 'done' } } - const mockAdapter = { schedule: jest.fn() } - const mockLogger = { info: jest.fn() } + const mockAdapter = { schedule: vi.fn() } + const mockLogger = { info: vi.fn() } RedwoodJob.config({ adapter: mockAdapter, logger: mockLogger }) - const spy = jest.spyOn(mockLogger, 'info') + const spy = vi.spyOn(mockLogger, 'info') await new TestJob().performLater('foo', 'bar') @@ -313,9 +315,9 @@ describe('instance performLater()', () => { return 'done' } } - const mockAdapter = { schedule: jest.fn() } + const mockAdapter = { schedule: vi.fn() } RedwoodJob.config({ adapter: mockAdapter }) - const spy = jest.spyOn(mockAdapter, 'schedule') + const spy = vi.spyOn(mockAdapter, 'schedule') await new TestJob().performLater('foo', 'bar') @@ -336,7 +338,7 @@ describe('instance performLater()', () => { } const scheduleReturn = { status: 'scheduled' } const mockAdapter = { - schedule: jest.fn(() => scheduleReturn), + schedule: vi.fn(() => scheduleReturn), } RedwoodJob.config({ adapter: mockAdapter }) @@ -352,7 +354,7 @@ describe('instance performLater()', () => { } } const mockAdapter = { - schedule: jest.fn(() => { + schedule: vi.fn(() => { throw new Error('Could not schedule') }), } @@ -372,7 +374,7 @@ describe('instance performLater()', () => { describe('static performNow()', () => { beforeEach(() => { - jest.clearAllMocks() + vi.clearAllMocks() }) test('invokes the instance performNow()', () => { @@ -381,8 +383,8 @@ describe('static performNow()', () => { return 'done' } } - const spy = jest.spyOn(TestJob.prototype, 'performNow') - const mockAdapter = { schedule: jest.fn() } + const spy = vi.spyOn(TestJob.prototype, 'performNow') + const mockAdapter = { schedule: vi.fn() } RedwoodJob.config({ adapter: mockAdapter }) TestJob.performNow('foo', 'bar') @@ -393,7 +395,7 @@ describe('static performNow()', () => { describe('instance performNow()', () => { beforeEach(() => { - jest.clearAllMocks() + vi.clearAllMocks() }) test('throws an error if perform() function is not implemented', async () => { @@ -411,10 +413,10 @@ describe('instance performNow()', () => { return 'done' } } - const mockAdapter = { schedule: jest.fn() } - const mockLogger = { info: jest.fn() } + const mockAdapter = { schedule: vi.fn() } + const mockLogger = { info: vi.fn() } RedwoodJob.config({ adapter: mockAdapter, logger: mockLogger }) - const spy = jest.spyOn(mockLogger, 'info') + const spy = vi.spyOn(mockLogger, 'info') await new TestJob().performNow('foo', 'bar') @@ -437,7 +439,7 @@ describe('instance performNow()', () => { } } - const spy = jest.spyOn(TestJob.prototype, 'perform') + const spy = vi.spyOn(TestJob.prototype, 'perform') await new TestJob().performNow('foo', 'bar') @@ -464,7 +466,7 @@ describe('instance performNow()', () => { } } const mockAdapter = { - schedule: jest.fn(() => { + schedule: vi.fn(() => { throw new Error('Could not schedule') }), } diff --git a/packages/jobs/src/core/__tests__/Worker.test.js b/packages/jobs/src/core/__tests__/Worker.test.js index 627e76aa7ab6..7b6f8c32ddae 100644 --- a/packages/jobs/src/core/__tests__/Worker.test.js +++ b/packages/jobs/src/core/__tests__/Worker.test.js @@ -1,10 +1,20 @@ +import { + describe, + expect, + vi, + test, + beforeAll, + afterAll, + afterEach, +} from 'vitest' + import * as errors from '../../core/errors' import { Executor } from '../Executor' import { Worker, DEFAULT_MAX_RUNTIME, DEFAULT_WAIT_TIME } from '../Worker' -jest.mock('../Executor') +vi.mock('../Executor') -jest.useFakeTimers().setSystemTime(new Date('2024-01-01')) +vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) describe('constructor', () => { test('saves options', () => { @@ -115,11 +125,11 @@ const originalConsoleDebug = console.debug describe('run', () => { beforeAll(() => { // hide console.debug output during test run - console.debug = jest.fn() + console.debug = vi.fn() }) afterEach(() => { - jest.resetAllMocks() + vi.resetAllMocks() }) afterAll(() => { @@ -128,7 +138,7 @@ describe('run', () => { }) test('tries to find a job', async () => { - const adapter = { find: jest.fn(() => null) } + const adapter = { find: vi.fn(() => null) } const worker = new Worker({ adapter, waitTime: 0, forever: false }) await worker.run() @@ -141,9 +151,9 @@ describe('run', () => { }) test('does nothing if no job found and forever=false', async () => { - const adapter = { find: jest.fn(() => null) } - const mockExecutor = jest.fn() - jest.mock('../Executor', () => ({ Executor: mockExecutor })) + const adapter = { find: vi.fn(() => null) } + const mockExecutor = vi.fn() + vi.mock('../Executor', () => ({ Executor: mockExecutor })) const worker = new Worker({ adapter, waitTime: 0, forever: false }) await worker.run() @@ -152,9 +162,9 @@ describe('run', () => { }) test('does nothing if no job found and workoff=true', async () => { - const adapter = { find: jest.fn(() => null) } - const mockExecutor = jest.fn() - jest.mock('../Executor', () => ({ Executor: mockExecutor })) + const adapter = { find: vi.fn(() => null) } + const mockExecutor = vi.fn() + vi.mock('../Executor', () => ({ Executor: mockExecutor })) const worker = new Worker({ adapter, waitTime: 0, workoff: true }) await worker.run() @@ -163,7 +173,7 @@ describe('run', () => { }) test('initializes an Executor instance if the job is found', async () => { - const adapter = { find: jest.fn(() => ({ id: 1 })) } + const adapter = { find: vi.fn(() => ({ id: 1 })) } const worker = new Worker({ adapter, waitTime: 0, forever: false }) await worker.run() @@ -176,8 +186,8 @@ describe('run', () => { }) test('calls `perform` on the Executor instance', async () => { - const adapter = { find: jest.fn(() => ({ id: 1 })) } - const spy = jest.spyOn(Executor.prototype, 'perform') + const adapter = { find: vi.fn(() => ({ id: 1 })) } + const spy = vi.spyOn(Executor.prototype, 'perform') const worker = new Worker({ adapter, waitTime: 0, forever: false }) await worker.run() @@ -186,8 +196,8 @@ describe('run', () => { }) test('calls `perform` on the Executor instance', async () => { - const adapter = { find: jest.fn(() => ({ id: 1 })) } - const spy = jest.spyOn(Executor.prototype, 'perform') + const adapter = { find: vi.fn(() => ({ id: 1 })) } + const spy = vi.spyOn(Executor.prototype, 'perform') const worker = new Worker({ adapter, waitTime: 0, forever: false }) await worker.run() diff --git a/packages/jobs/vitest.config.mjs b/packages/jobs/vitest.config.mjs new file mode 100644 index 000000000000..5b2b3ea44977 --- /dev/null +++ b/packages/jobs/vitest.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig, configDefaults } from 'vitest/config' + +export default defineConfig({ + test: { + testTimeout: 15_000, + include: ['**/__tests__/**/*.[jt]s?(x)', '**/*.test.[jt]s?(x)'], + exclude: [...configDefaults.exclude, '**/fixtures', '**/dist'], + }, +}) From ff74491b889cdd64d99d4bb7885ba0985f8a8371 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 27 Jun 2024 00:03:38 -0700 Subject: [PATCH 010/258] Update deps, switch to vitest --- packages/jobs/package.json | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/jobs/package.json b/packages/jobs/package.json index 7999752abc85..b11fcc9ea159 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -21,19 +21,17 @@ "build:types": "tsc --build --verbose", "build:watch": "nodemon --watch src --ext \"js,jsx,ts,tsx\" --ignore dist --exec \"yarn build\"", "prepublishOnly": "NODE_ENV=production yarn build", - "test": "jest src", + "test": "vitest run", "test:watch": "yarn test --watch" }, "dependencies": { - "@babel/runtime-corejs3": "7.24.5", - "core-js": "3.37.1", "fast-glob": "3.3.2" }, "devDependencies": { "@redwoodjs/project-config": "workspace:*", - "jest": "29.7.0", "tsx": "4.15.6", - "typescript": "5.4.5" + "typescript": "5.4.5", + "vitest": "1.6.0" }, "gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1" } From 8653bb6b576f405ef4b8384c3a999cd6db5b3042 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 27 Jun 2024 00:03:43 -0700 Subject: [PATCH 011/258] New build config --- packages/jobs/build.mts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/jobs/build.mts b/packages/jobs/build.mts index 16175a6725c0..a607d8f03817 100644 --- a/packages/jobs/build.mts +++ b/packages/jobs/build.mts @@ -1,3 +1,8 @@ -import { build } from '@redwoodjs/framework-tools' +import { build, defaultBuildOptions } from '@redwoodjs/framework-tools' -await build() +await build({ + buildOptions: { + ...defaultBuildOptions, + format: 'cjs', + }, +}) From c5d3112975e306572332134ffb82dd5862609ffd Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 27 Jun 2024 00:03:50 -0700 Subject: [PATCH 012/258] Don't need babelrc --- packages/jobs/.babelrc.js | 1 - 1 file changed, 1 deletion(-) delete mode 100644 packages/jobs/.babelrc.js diff --git a/packages/jobs/.babelrc.js b/packages/jobs/.babelrc.js deleted file mode 100644 index 3b2c815712d9..000000000000 --- a/packages/jobs/.babelrc.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = { extends: '../../babel.config.js' } From 2f30187f1436a090b526d877c80b4ad08c2774de Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 27 Jun 2024 00:03:59 -0700 Subject: [PATCH 013/258] Update .lock --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6f0bbd3fe067..4b5eb7d175e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8319,13 +8319,13 @@ __metadata: version: 0.0.0-use.local resolution: "@redwoodjs/jobs@workspace:packages/jobs" dependencies: - "@babel/runtime-corejs3": "npm:7.24.5" "@redwoodjs/project-config": "workspace:*" - core-js: "npm:3.37.1" fast-glob: "npm:3.3.2" - jest: "npm:29.7.0" tsx: "npm:4.15.6" typescript: "npm:5.4.5" + vitest: "npm:1.6.0" + bin: + rw-jobs: ./dist/bins/runner.js languageName: unknown linkType: soft From d932690b212a4af436915126087987ff93780484 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 27 Jun 2024 19:28:15 -0700 Subject: [PATCH 014/258] Catch any error importing logger, just log to console instead --- packages/jobs/src/bins/shared.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/jobs/src/bins/shared.js b/packages/jobs/src/bins/shared.js index a46d82093377..9a92772a7de8 100644 --- a/packages/jobs/src/bins/shared.js +++ b/packages/jobs/src/bins/shared.js @@ -26,10 +26,11 @@ export const loadAdapter = async () => { } export const loadLogger = async () => { - // try { - const { default: loggerModule } = await import(getPaths().api.logger) - return loggerModule.logger - // } catch (e) { - // // import didn't work for whatever reason, fall back to console - // } + try { + const { default: loggerModule } = await import(getPaths().api.logger) + return loggerModule.logger + } catch (e) { + // import didn't work for whatever reason, fall back to console + return console + } } From 30a24df6224cfb8c5d1389fe0fc8ef40d52c595f Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 27 Jun 2024 19:35:49 -0700 Subject: [PATCH 015/258] Catch errors if api/src/lib/jobs doesn't exist, or doesn't export `adapter` --- packages/jobs/src/bins/shared.js | 11 +++++------ packages/jobs/src/bins/worker.js | 13 ++++++------- packages/jobs/src/core/errors.js | 4 +--- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/packages/jobs/src/bins/shared.js b/packages/jobs/src/bins/shared.js index 9a92772a7de8..e90627c6b70b 100644 --- a/packages/jobs/src/bins/shared.js +++ b/packages/jobs/src/bins/shared.js @@ -13,13 +13,12 @@ registerApiSideBabelHook() export const loadAdapter = async () => { if (getPaths().api.jobs) { - // try { const { default: jobsModule } = await import(getPaths().api.jobs) - return jobsModule.adapter - // } catch (e) { - // // api/src/lib/jobs.js doesn't exist or doesn't export `adapter` - // throw new AdapterNotFoundError() - // } + if (jobsModule.adapter) { + return jobsModule.adapter + } else { + throw new AdapterNotFoundError() + } } else { throw new JobsLibNotFoundError() } diff --git a/packages/jobs/src/bins/worker.js b/packages/jobs/src/bins/worker.js index 715ecf162cf5..460911f9f62b 100755 --- a/packages/jobs/src/bins/worker.js +++ b/packages/jobs/src/bins/worker.js @@ -89,13 +89,12 @@ const main = async () => { const logger = await loadLogger() let adapter - // try { - adapter = await loadAdapter(logger) - // } catch (e) { - // // TODO check for file not existing vs not exporting `adapter` - // logger.error(e) - // process.exit(1) - // } + try { + adapter = await loadAdapter(logger) + } catch (e) { + logger.error(e) + process.exit(1) + } logger.info( { worker: process.title }, diff --git a/packages/jobs/src/core/errors.js b/packages/jobs/src/core/errors.js index df0d130630a0..36c0cc9fbb8f 100644 --- a/packages/jobs/src/core/errors.js +++ b/packages/jobs/src/core/errors.js @@ -114,8 +114,6 @@ export class JobsLibNotFoundError extends RedwoodJobError { // Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js export class AdapterNotFoundError extends RedwoodJobError { constructor() { - super( - 'api/src/lib/jobs.js does not export `adapter`. Create this file and export `adapter` for the job runner to use', - ) + super('api/src/lib/jobs.js does not export `adapter`') } } From ba92f60fd48bc09696cab0b1896ae31d32671e41 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 27 Jun 2024 20:11:40 -0700 Subject: [PATCH 016/258] Add paths for jobs --- packages/project-config/src/paths.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/project-config/src/paths.ts b/packages/project-config/src/paths.ts index 70a1f19c19a2..420690a0ca7b 100644 --- a/packages/project-config/src/paths.ts +++ b/packages/project-config/src/paths.ts @@ -95,6 +95,7 @@ const PATH_RW_SCRIPTS = 'scripts' const PATH_API_DIR_GRAPHQL = 'api/src/graphql' const PATH_API_DIR_CONFIG = 'api/src/config' const PATH_API_DIR_MODELS = 'api/src/models' +const PATH_API_DIR_JOBS = 'api/src/jobs' const PATH_API_DIR_LIB = 'api/src/lib' const PATH_API_DIR_GENERATORS = 'api/generators' const PATH_API_DIR_SERVICES = 'api/src/services' @@ -199,7 +200,8 @@ export const getPaths = (BASE_DIR: string = getBaseDir()): Paths => { dbSchema: path.join(BASE_DIR, schemaPath), functions: path.join(BASE_DIR, PATH_API_DIR_FUNCTIONS), graphql: path.join(BASE_DIR, PATH_API_DIR_GRAPHQL), - jobs: resolveFile(path.join(BASE_DIR, PATH_API_DIR_LIB, 'jobs')), + jobs: path.join(path.join(BASE_DIR, PATH_API_DIR_JOBS)), + jobsConfig: resolveFile(path.join(BASE_DIR, PATH_API_DIR_LIB, 'jobs')), lib: path.join(BASE_DIR, PATH_API_DIR_LIB), logger: resolveFile(path.join(BASE_DIR, PATH_API_DIR_LIB, 'logger')), generators: path.join(BASE_DIR, PATH_API_DIR_GENERATORS), From d07ed753f184a8de6cc15c5ce3736bb3db5d2356 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 27 Jun 2024 20:11:55 -0700 Subject: [PATCH 017/258] Move bins/shared to core/loaders --- .../src/{bins/shared.js => core/loaders.js} | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) rename packages/jobs/src/{bins/shared.js => core/loaders.js} (58%) diff --git a/packages/jobs/src/bins/shared.js b/packages/jobs/src/core/loaders.js similarity index 58% rename from packages/jobs/src/bins/shared.js rename to packages/jobs/src/core/loaders.js index e90627c6b70b..bbeeaed9c82e 100644 --- a/packages/jobs/src/bins/shared.js +++ b/packages/jobs/src/core/loaders.js @@ -1,19 +1,21 @@ import console from 'node:console' import path from 'node:path' -import fg from 'fast-glob' - import { registerApiSideBabelHook } from '@redwoodjs/babel-config' -import { getPaths } from '@redwoodjs/project-config' +import { getPaths, resolveFile } from '@redwoodjs/project-config' -import { AdapterNotFoundError, JobsLibNotFoundError } from '../core/errors' +import { + AdapterNotFoundError, + JobsLibNotFoundError, + JobNotFoundError, +} from './errors' // TODO Don't use this in production, import from dist directly registerApiSideBabelHook() export const loadAdapter = async () => { if (getPaths().api.jobs) { - const { default: jobsModule } = await import(getPaths().api.jobs) + const { default: jobsModule } = await import(getPaths().api.jobsConfig) if (jobsModule.adapter) { return jobsModule.adapter } else { @@ -29,7 +31,20 @@ export const loadLogger = async () => { const { default: loggerModule } = await import(getPaths().api.logger) return loggerModule.logger } catch (e) { - // import didn't work for whatever reason, fall back to console return console } } + +export const loadJob = async (name) => { + try { + const filename = resolveFile(path.join(getPaths().api.jobs, name)) + const { default: jobModule } = await import(filename) + return jobModule + } catch (e) { + if (e.code === 'ERR_MODULE_NOT_FOUND') { + throw new JobNotFoundError(name) + } else { + throw e + } + } +} From 2f5eacd99ff752635e4b4a1ecb8e088b1f7561f0 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 27 Jun 2024 20:12:16 -0700 Subject: [PATCH 018/258] Load job from app (only works for jobs in the root path for now) --- packages/jobs/src/core/Executor.js | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/jobs/src/core/Executor.js b/packages/jobs/src/core/Executor.js index d9147a433f61..f73c3f894df0 100644 --- a/packages/jobs/src/core/Executor.js +++ b/packages/jobs/src/core/Executor.js @@ -2,13 +2,12 @@ import console from 'node:console' -import fg from 'fast-glob' - import { AdapterRequiredError, JobRequiredError, - JobNotFoundError, + JobExportNotFoundError, } from './errors' +import { loadJob } from './loaders' export class Executor { constructor(options) { @@ -27,21 +26,19 @@ export class Executor { async perform() { this.logger.info(this.job, `Started job ${this.job.id}`) + const details = JSON.parse(this.job.handler) try { - const details = JSON.parse(this.job.handler) - const entries = await fg(`./**/${details.handler}.js`, { cwd: __dirname }) - if (!entries[0]) { - throw new JobNotFoundError(details.handler) - } - - const Job = await import(`./${entries[0]}`) - await new Job[details.handler]().perform(...details.args) - + const jobModule = await loadJob(details.handler) + await new jobModule[details.handler]().perform(...details.args) return this.adapter.success(this.job) } catch (e) { - this.logger.error(e.stack) - return this.adapter.failure(this.job, e) + let error = e + if (e.message.match(/is not a constructor/)) { + error = new JobExportNotFoundError(details.handler) + } + this.logger.error(error.stack) + return this.adapter.failure(this.job, error) } } } From d5cfd0bb0e427b7c27d25180669860fca2d1826b Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 27 Jun 2024 20:12:26 -0700 Subject: [PATCH 019/258] Adds error for job not exporting a named member --- packages/jobs/src/core/errors.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/jobs/src/core/errors.js b/packages/jobs/src/core/errors.js index 36c0cc9fbb8f..cced9a842e58 100644 --- a/packages/jobs/src/core/errors.js +++ b/packages/jobs/src/core/errors.js @@ -101,6 +101,13 @@ export class JobNotFoundError extends RedwoodJobError { } } +// Throw when a job file exists, but the export does not match the filename +export class JobExportNotFoundError extends RedwoodJobError { + constructor(name) { + super(`Job file \`${name}\` does not export a class with the same name`) + } +} + // Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js and // the file does not exist export class JobsLibNotFoundError extends RedwoodJobError { From 31a203828d87df0810b9f1a94a4cdf2518b1e56c Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 27 Jun 2024 20:12:42 -0700 Subject: [PATCH 020/258] Update import locations --- packages/jobs/src/bins/runner.js | 2 +- packages/jobs/src/bins/worker.js | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/jobs/src/bins/runner.js b/packages/jobs/src/bins/runner.js index d8ac98b4e5e6..8b732254e5cf 100755 --- a/packages/jobs/src/bins/runner.js +++ b/packages/jobs/src/bins/runner.js @@ -12,7 +12,7 @@ import yargs from 'yargs/yargs' import { loadEnvFiles } from '@redwoodjs/cli/dist/lib/loadEnvFiles' -import { loadLogger } from './shared' +import { loadLogger } from '../core/loaders' loadEnvFiles() diff --git a/packages/jobs/src/bins/worker.js b/packages/jobs/src/bins/worker.js index 460911f9f62b..dcae4e08e305 100755 --- a/packages/jobs/src/bins/worker.js +++ b/packages/jobs/src/bins/worker.js @@ -10,10 +10,9 @@ import yargs from 'yargs/yargs' import { loadEnvFiles } from '@redwoodjs/cli/dist/lib/loadEnvFiles' loadEnvFiles() +import { loadAdapter, loadLogger } from '../core/loaders' import { Worker } from '../core/Worker' -import { loadAdapter, loadLogger } from './shared' - const TITLE_PREFIX = `rw-job-worker` const parseArgs = (argv) => { From 4c13b89c554bf709b9e244c57cd229f8660308e7 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 27 Jun 2024 20:13:00 -0700 Subject: [PATCH 021/258] Include process title in log message --- packages/jobs/src/bins/worker.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/jobs/src/bins/worker.js b/packages/jobs/src/bins/worker.js index dcae4e08e305..4821c6d0756c 100755 --- a/packages/jobs/src/bins/worker.js +++ b/packages/jobs/src/bins/worker.js @@ -63,8 +63,7 @@ const setupSignals = ({ worker, logger }) => { // it not to pick up a new job when done with the current one process.on('SIGINT', () => { logger.warn( - { worker: process.title }, - `SIGINT received at ${new Date().toISOString()}, finishing work...`, + `[${process.title}] SIGINT received at ${new Date().toISOString()}, finishing work...`, ) worker.forever = false }) @@ -74,8 +73,7 @@ const setupSignals = ({ worker, logger }) => { // in process.on('SIGTERM', () => { logger.info( - { worker: process.title }, - `SIGTERM received at ${new Date().toISOString()}, exiting now!`, + `[${process.title}] SIGTERM received at ${new Date().toISOString()}, exiting now!`, ) process.exit(0) }) @@ -96,8 +94,7 @@ const main = async () => { } logger.info( - { worker: process.title }, - `Starting work at ${new Date().toISOString()}...`, + `[${process.title}] Starting work at ${new Date().toISOString()}...`, ) const worker = new Worker({ @@ -110,7 +107,7 @@ const main = async () => { }) worker.run().then(() => { - logger.info({ worker: process.title }, `Worker finished, shutting down.`) + logger.info(`[${process.title}] Worker finished, shutting down.`) process.exit(0) }) From 93ffcd9a9b44f137d1dc359604a685a46517bb62 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 27 Jun 2024 20:13:10 -0700 Subject: [PATCH 022/258] Add debug logging for job success and failure --- packages/jobs/src/adapters/PrismaAdapter.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/jobs/src/adapters/PrismaAdapter.js b/packages/jobs/src/adapters/PrismaAdapter.js index dbb4b3c2e631..8d15ed1ad570 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.js +++ b/packages/jobs/src/adapters/PrismaAdapter.js @@ -84,10 +84,12 @@ export class PrismaAdapter extends BaseAdapter { } success(job) { + this.logger.debug(`Job ${job.id} success`) return this.accessor.delete({ where: { id: job.id } }) } async failure(job, error) { + this.logger.debug(`Job ${job.id} failure`) const data = { lockedAt: null, lockedBy: null, From 1de0a442fa39fa63e5014f3e523d14ed8c70eea0 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 27 Jun 2024 20:13:27 -0700 Subject: [PATCH 023/258] Use console if logger not yet, remove private #log() function --- packages/jobs/src/adapters/BaseAdapter.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/jobs/src/adapters/BaseAdapter.js b/packages/jobs/src/adapters/BaseAdapter.js index 81573f431204..1a90fa90eeb5 100644 --- a/packages/jobs/src/adapters/BaseAdapter.js +++ b/packages/jobs/src/adapters/BaseAdapter.js @@ -5,12 +5,14 @@ // be used to configure your custom adapter. If `options.logger` is included // you can access it via `this.logger` +import console from 'node:console' + import { NotImplementedError } from '../core/errors' export class BaseAdapter { constructor(options) { this.options = options - this.logger = options?.logger + this.logger = options?.logger || console } schedule() { @@ -32,8 +34,4 @@ export class BaseAdapter { failure() { throw new NotImplementedError('failure') } - - #log(message, { level = 'info' }) { - this.logger[level](message) - } } From c927e35b0ff5ceeafcbc3222b80b4b55212ea76a Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 28 Jun 2024 10:45:31 -0700 Subject: [PATCH 024/258] Find job in any subdirectory of api/src/jobs --- packages/jobs/src/core/loaders.js | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/jobs/src/core/loaders.js b/packages/jobs/src/core/loaders.js index bbeeaed9c82e..6ba7e746023a 100644 --- a/packages/jobs/src/core/loaders.js +++ b/packages/jobs/src/core/loaders.js @@ -1,8 +1,10 @@ import console from 'node:console' import path from 'node:path' +import fg from 'fast-glob' + import { registerApiSideBabelHook } from '@redwoodjs/babel-config' -import { getPaths, resolveFile } from '@redwoodjs/project-config' +import { getPaths } from '@redwoodjs/project-config' import { AdapterNotFoundError, @@ -36,15 +38,12 @@ export const loadLogger = async () => { } export const loadJob = async (name) => { - try { - const filename = resolveFile(path.join(getPaths().api.jobs, name)) - const { default: jobModule } = await import(filename) - return jobModule - } catch (e) { - if (e.code === 'ERR_MODULE_NOT_FOUND') { - throw new JobNotFoundError(name) - } else { - throw e - } + const files = fg.sync(`**/${name}.*`, { cwd: getPaths().api.jobs }) + if (!files[0]) { + throw new JobNotFoundError(name) } + const { default: jobModule } = await import( + path.join(getPaths().api.jobs, files[0]) + ) + return jobModule } From 8b12c7760b131248829181e70216214acfcd5f4c Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 28 Jun 2024 10:46:25 -0700 Subject: [PATCH 025/258] Comment loaders --- packages/jobs/src/core/loaders.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/jobs/src/core/loaders.js b/packages/jobs/src/core/loaders.js index 6ba7e746023a..c1441bced4af 100644 --- a/packages/jobs/src/core/loaders.js +++ b/packages/jobs/src/core/loaders.js @@ -15,6 +15,7 @@ import { // TODO Don't use this in production, import from dist directly registerApiSideBabelHook() +// Loads the exported adapter from the app's jobs config in api/src/lib/jobs.js export const loadAdapter = async () => { if (getPaths().api.jobs) { const { default: jobsModule } = await import(getPaths().api.jobsConfig) @@ -28,6 +29,7 @@ export const loadAdapter = async () => { } } +// Loads the logger from the app's filesystem in api/src/lib/logger.js export const loadLogger = async () => { try { const { default: loggerModule } = await import(getPaths().api.logger) @@ -37,6 +39,7 @@ export const loadLogger = async () => { } } +// Loads a job from the app's filesystem in api/src/jobs export const loadJob = async (name) => { const files = fg.sync(`**/${name}.*`, { cwd: getPaths().api.jobs }) if (!files[0]) { From d8fbab0f6aadf0cd54f9b793162fce6c7232f51c Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 28 Jun 2024 11:17:06 -0700 Subject: [PATCH 026/258] Import setTimeout --- packages/jobs/src/bins/runner.js | 1 + packages/jobs/src/core/Worker.js | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/jobs/src/bins/runner.js b/packages/jobs/src/bins/runner.js index 8b732254e5cf..910eaec4bfdc 100755 --- a/packages/jobs/src/bins/runner.js +++ b/packages/jobs/src/bins/runner.js @@ -6,6 +6,7 @@ import { fork, exec } from 'node:child_process' import path from 'node:path' import process from 'node:process' +import { setTimeout } from 'node:timers' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' diff --git a/packages/jobs/src/core/Worker.js b/packages/jobs/src/core/Worker.js index 7f7a74974556..b47a869ba083 100644 --- a/packages/jobs/src/core/Worker.js +++ b/packages/jobs/src/core/Worker.js @@ -2,6 +2,7 @@ import console from 'node:console' import process from 'node:process' +import { setTimeout } from 'node:timers' import { AdapterRequiredError } from './errors' import { Executor } from './Executor' From 91ca380041e61234b91786945b91ab12e520dadd Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 28 Jun 2024 11:17:15 -0700 Subject: [PATCH 027/258] Remove index file in adapters --- packages/jobs/src/adapters/index.js | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 packages/jobs/src/adapters/index.js diff --git a/packages/jobs/src/adapters/index.js b/packages/jobs/src/adapters/index.js deleted file mode 100644 index 77669ba2f239..000000000000 --- a/packages/jobs/src/adapters/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { BaseAdapter } from './BaseAdapter' -export { PrismaAdapter } from './PrismaAdapter' From 192e9292a4000b9546cee00335fc452300f9f617 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 28 Jun 2024 11:17:47 -0700 Subject: [PATCH 028/258] Comments, remove unused args --- packages/jobs/src/bins/runner.js | 14 +++-- packages/jobs/src/core/RedwoodJob.js | 6 +- packages/jobs/src/core/errors.js | 91 ++++++++++++++-------------- 3 files changed, 57 insertions(+), 54 deletions(-) diff --git a/packages/jobs/src/bins/runner.js b/packages/jobs/src/bins/runner.js index 910eaec4bfdc..9877ee0633ec 100755 --- a/packages/jobs/src/bins/runner.js +++ b/packages/jobs/src/bins/runner.js @@ -83,8 +83,8 @@ const parseArgs = (argv) => { const buildWorkerConfig = (numWorkers) => { // Builds up an array of arrays, with queue name and id: // `-n default:2,email:1` => [ ['default', 0], ['default', 1], ['email', 0] ] - // If only given a number of workers then queue name is an empty string: - // `-n 2` => [ ['', 0], ['', 1] ] + // If only given a number of workers then queue name is null (all queues): + // `-n 2` => [ [null, 0], [null, 1] ] let workers = [] // default to one worker for commands that don't specify @@ -116,7 +116,7 @@ const startWorkers = ({ }) => { logger.warn(`Starting ${workerConfig.length} worker(s)...`) - return workerConfig.map(([queue, id], i) => { + return workerConfig.map(([queue, id]) => { // list of args to send to the forked worker script const workerArgs = ['--id', id] @@ -131,6 +131,7 @@ const startWorkers = ({ } // fork the worker process + // TODO squiggles under __dirname, but import.meta.dirname blows up when running the process const worker = fork(path.join(__dirname, 'worker.js'), workerArgs, { detached: detach, stdio: detach ? 'ignore' : 'inherit', @@ -139,7 +140,7 @@ const startWorkers = ({ if (detach) { worker.unref() } else { - // children stay attached so watch for their exit + // children stay attached so watch for their exit before exiting parent worker.on('exit', (_code) => {}) } @@ -148,7 +149,7 @@ const startWorkers = ({ } const signalSetup = ({ workers, logger }) => { - // if we get here then we're still monitoring workers and have to pass on signals + // Keep track of how many times the user has pressed ctrl-c let sigtermCount = 0 // If the parent receives a ctrl-c, tell each worker to gracefully exit. @@ -170,6 +171,7 @@ const signalSetup = ({ workers, logger }) => { }) } +// Find the process id of a worker by its title const findProcessId = async (proc) => { return new Promise(function (resolve, reject) { const plat = process.platform @@ -184,7 +186,7 @@ const findProcessId = async (proc) => { if (cmd === '' || proc === '') { resolve(false) } - exec(cmd, function (err, stdout, _stderr) { + exec(cmd, function (err, stdout) { if (err) { reject(err) } diff --git a/packages/jobs/src/core/RedwoodJob.js b/packages/jobs/src/core/RedwoodJob.js index 9c2c957146a8..eb0e5f89894e 100644 --- a/packages/jobs/src/core/RedwoodJob.js +++ b/packages/jobs/src/core/RedwoodJob.js @@ -150,9 +150,9 @@ export class RedwoodJob { // Determines when the job should run. // - // * If no options were set, defaults to running as soon as possible - // * If a `wait` option is present it sets the number of seconds to wait - // * If a `waitUntil` option is present it runs at that specific datetime + // - If no options were set, defaults to running as soon as possible + // - If a `wait` option is present it sets the number of seconds to wait + // - If a `waitUntil` option is present it runs at that specific datetime get runAt() { if (!this.#options?.runAt) { this.#options = Object.assign(this.#options || {}, { diff --git a/packages/jobs/src/core/errors.js b/packages/jobs/src/core/errors.js index cced9a842e58..17d152cb939b 100644 --- a/packages/jobs/src/core/errors.js +++ b/packages/jobs/src/core/errors.js @@ -27,12 +27,58 @@ export class NotImplementedError extends RedwoodJobError { } } +// Thrown when a given model name isn't actually available in the PrismaClient export class ModelNameError extends RedwoodJobError { constructor(name) { super(`Model \`${name}\` not found in PrismaClient`) } } +// Thrown when the Executor is instantiated without an adapter +export class AdapterRequiredError extends RedwoodJobError { + constructor() { + super('`adapter` is required to perform a job') + } +} + +// Thrown when the Executor is instantiated without a job +export class JobRequiredError extends RedwoodJobError { + constructor() { + super('`job` is required to perform a job') + } +} + +// Thrown when a job with the given handler is not found in the filesystem +export class JobNotFoundError extends RedwoodJobError { + constructor(name) { + super(`Job \`${name}\` not found in the filesystem`) + } +} + +// Throw when a job file exists, but the export does not match the filename +export class JobExportNotFoundError extends RedwoodJobError { + constructor(name) { + super(`Job file \`${name}\` does not export a class with the same name`) + } +} + +// Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js and +// the file does not exist +export class JobsLibNotFoundError extends RedwoodJobError { + constructor() { + super( + 'api/src/lib/jobs.js not found. Create this file and export `adapter` for the job runner to use', + ) + } +} + +// Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js +export class AdapterNotFoundError extends RedwoodJobError { + constructor() { + super('api/src/lib/jobs.js does not export `adapter`') + } +} + // Parent class for any job where we want to wrap the underlying error in our // own. Use by extending this class and passing the original error to the // constructor: @@ -79,48 +125,3 @@ export class PerformError extends RethrownJobError { super(message, error) } } - -// Thrown when the Executor is instantiated without an adapter -export class AdapterRequiredError extends RedwoodJobError { - constructor() { - super('`adapter` is required to perform a job') - } -} - -// Thrown when the Executor is instantiated without a job -export class JobRequiredError extends RedwoodJobError { - constructor() { - super('`job` is required to perform a job') - } -} - -// Throw when a job with the given handler is not found in the filesystem -export class JobNotFoundError extends RedwoodJobError { - constructor(name) { - super(`Job \`${name}\` not found in the filesystem`) - } -} - -// Throw when a job file exists, but the export does not match the filename -export class JobExportNotFoundError extends RedwoodJobError { - constructor(name) { - super(`Job file \`${name}\` does not export a class with the same name`) - } -} - -// Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js and -// the file does not exist -export class JobsLibNotFoundError extends RedwoodJobError { - constructor() { - super( - 'api/src/lib/jobs.js not found. Create this file and export `adapter` for the job runner to use', - ) - } -} - -// Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js -export class AdapterNotFoundError extends RedwoodJobError { - constructor() { - super('api/src/lib/jobs.js does not export `adapter`') - } -} From 7d905085c8a40fef56463c2e67f00437182984ac Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 28 Jun 2024 13:36:55 -0700 Subject: [PATCH 029/258] Convert sqlite query to regular prisma calls --- packages/jobs/src/adapters/PrismaAdapter.js | 78 +++++++++++++-------- 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/packages/jobs/src/adapters/PrismaAdapter.js b/packages/jobs/src/adapters/PrismaAdapter.js index 8d15ed1ad570..c6102935a607 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.js +++ b/packages/jobs/src/adapters/PrismaAdapter.js @@ -132,48 +132,64 @@ export class PrismaAdapter extends BaseAdapter { return 1000 * attempts ** 4 } - // TODO can this be converted to use standard Prisma queries? async #sqliteFind({ processName, maxRuntime, queue }) { - const where = ` - ( - ( - ${queue ? `queue = '${queue}' AND` : ''} - runAt <= ${new Date().getTime()} AND ( - lockedAt IS NULL OR - lockedAt < ${new Date(new Date() - maxRuntime).getTime()} - ) OR lockedBy = '${processName}' - ) AND failedAt IS NULL - ) - ` + const maxRuntimeExpire = new Date(new Date() - maxRuntime) - // Find any jobs that should run now. Look for ones that: + // This query is gnarly but not so bad once you know what it's doing. For a + // job to match it must: // - have a runtAt in the past - // - and are either not locked, or were locked more than `maxRuntime` ago - // - or were already locked by this exact process and never cleaned up + // - is either not locked, or was locked more than `maxRuntime` ago, + // or was already locked by this exact process and never cleaned up // - and don't have a failedAt, meaning we will stop retrying - let outstandingJobs = await this.db.$queryRawUnsafe(` - SELECT id, attempts - FROM ${this.tableName} - WHERE ${where} - ORDER BY priority ASC, runAt ASC - LIMIT 1;`) + const prismaWhere = { + AND: [ + { runAt: { lte: new Date() } }, + { + OR: [ + { lockedAt: null }, + { + lockedAt: { + lt: maxRuntimeExpire, + }, + }, + { lockedBy: processName }, + ], + }, + { failedAt: null }, + ], + } - if (outstandingJobs.length) { - const id = outstandingJobs[0].id + // for some reason prisma doesn't like it's own `query: { not: null }` + // syntax, so only add the query condition if we're filtering by queue + if (queue) { + prismaWhere.AND.unshift({ queue }) + } + + // Find the next job that should run now + let job = await this.accessor.findFirst({ + select: { id: true, attempts: true }, + where: prismaWhere, + orderBy: [{ priority: 'asc' }, { runAt: 'asc' }], + take: 1, + }) + if (job) { // If one was found, try to lock it by updating the record with the // same WHERE clause as above (if another locked in the meantime it won't // find any record to update) - const updatedCount = await this.db.$queryRawUnsafe(` - UPDATE ${this.tableName} - SET lockedAt = ${new Date().getTime()}, - lockedBy = '${processName}', - attempts = ${outstandingJobs[0].attempts + 1} - WHERE ${where} AND id = ${id};`) + prismaWhere.AND.push({ id: job.id }) + const { count } = await this.accessor.updateMany({ + where: prismaWhere, + data: { + lockedAt: new Date(), + lockedBy: processName, + attempts: job.attempts + 1, + }, + }) // Assuming the update worked, return the job - if (updatedCount) { - return this.accessor.findFirst({ where: { id } }) + if (count) { + return this.accessor.findFirst({ where: { id: job.id } }) } } From f6caf734fd6f2dbf5f15123f02f5359b451174ee Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 28 Jun 2024 13:37:16 -0700 Subject: [PATCH 030/258] Update worker debug message to include queue name being searched --- packages/jobs/src/core/Worker.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/jobs/src/core/Worker.js b/packages/jobs/src/core/Worker.js index b47a869ba083..3c067e27cfd0 100644 --- a/packages/jobs/src/core/Worker.js +++ b/packages/jobs/src/core/Worker.js @@ -75,7 +75,9 @@ export class Worker { do { this.lastCheckTime = new Date() - this.logger.debug(`[${this.processName}] Checking for jobs...`) + this.logger.debug( + `[${this.processName}] Checking for jobs in ${this.queue ? `${this.queue} queue` : 'all queues'}...`, + ) const job = await this.adapter.find({ processName: this.processName, From 67fb339efc88b07ad20d72c6667f7ce6f4f92efd Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 28 Jun 2024 13:50:33 -0700 Subject: [PATCH 031/258] Comments --- packages/jobs/src/adapters/PrismaAdapter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jobs/src/adapters/PrismaAdapter.js b/packages/jobs/src/adapters/PrismaAdapter.js index c6102935a607..c30711399745 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.js +++ b/packages/jobs/src/adapters/PrismaAdapter.js @@ -140,7 +140,7 @@ export class PrismaAdapter extends BaseAdapter { // - have a runtAt in the past // - is either not locked, or was locked more than `maxRuntime` ago, // or was already locked by this exact process and never cleaned up - // - and don't have a failedAt, meaning we will stop retrying + // - and doesn't have a failedAt, meaning we will stop retrying const prismaWhere = { AND: [ { runAt: { lte: new Date() } }, From 626bceefb5f6f66fd010326288e535031a4e8cab Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 28 Jun 2024 15:54:44 -0700 Subject: [PATCH 032/258] Make sure await imports work on windows --- packages/jobs/src/core/loaders.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/jobs/src/core/loaders.js b/packages/jobs/src/core/loaders.js index c1441bced4af..8913aa9eacf0 100644 --- a/packages/jobs/src/core/loaders.js +++ b/packages/jobs/src/core/loaders.js @@ -1,5 +1,6 @@ import console from 'node:console' import path from 'node:path' +import { pathToFileURL } from 'node:url' import fg from 'fast-glob' @@ -15,10 +16,16 @@ import { // TODO Don't use this in production, import from dist directly registerApiSideBabelHook() +export function makeFilePath(path) { + return pathToFileURL(path).href +} + // Loads the exported adapter from the app's jobs config in api/src/lib/jobs.js export const loadAdapter = async () => { if (getPaths().api.jobs) { - const { default: jobsModule } = await import(getPaths().api.jobsConfig) + const { default: jobsModule } = await import( + makeFilePath(getPaths().api.jobsConfig) + ) if (jobsModule.adapter) { return jobsModule.adapter } else { @@ -32,7 +39,9 @@ export const loadAdapter = async () => { // Loads the logger from the app's filesystem in api/src/lib/logger.js export const loadLogger = async () => { try { - const { default: loggerModule } = await import(getPaths().api.logger) + const { default: loggerModule } = await import( + makeFilePath(getPaths().api.logger) + ) return loggerModule.logger } catch (e) { return console @@ -46,7 +55,7 @@ export const loadJob = async (name) => { throw new JobNotFoundError(name) } const { default: jobModule } = await import( - path.join(getPaths().api.jobs, files[0]) + makeFilePath(path.join(getPaths().api.jobs, files[0])) ) return jobModule } From 0b4222b7c18fd355ffd0ffea5012401cb291bb6b Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 28 Jun 2024 15:55:05 -0700 Subject: [PATCH 033/258] Fix some jest -> vi mocking differences --- .../jobs/src/core/__tests__/Executor.test.js | 3 +++ .../jobs/src/core/__tests__/Worker.test.js | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/jobs/src/core/__tests__/Executor.test.js b/packages/jobs/src/core/__tests__/Executor.test.js index 8bf76ba34fed..9d74a055030b 100644 --- a/packages/jobs/src/core/__tests__/Executor.test.js +++ b/packages/jobs/src/core/__tests__/Executor.test.js @@ -3,6 +3,9 @@ import { describe, expect, vi, test } from 'vitest' import * as errors from '../../core/errors' import { Executor } from '../Executor' +// so that registerApiSideBabelHook() doesn't freak out about redwood.toml +vi.mock('@redwoodjs/babel-config') + describe('constructor', () => { test('saves options', () => { const options = { adapter: 'adapter', job: 'job' } diff --git a/packages/jobs/src/core/__tests__/Worker.test.js b/packages/jobs/src/core/__tests__/Worker.test.js index 7b6f8c32ddae..de440a9fe84f 100644 --- a/packages/jobs/src/core/__tests__/Worker.test.js +++ b/packages/jobs/src/core/__tests__/Worker.test.js @@ -12,8 +12,13 @@ import * as errors from '../../core/errors' import { Executor } from '../Executor' import { Worker, DEFAULT_MAX_RUNTIME, DEFAULT_WAIT_TIME } from '../Worker' +// don't execute any code inside Executor, just spy on whether functions are +// called vi.mock('../Executor') +// so that registerApiSideBabelHook() doesn't freak out about redwood.toml +vi.mock('@redwoodjs/babel-config') + vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) describe('constructor', () => { @@ -24,7 +29,7 @@ describe('constructor', () => { expect(worker.options).toEqual(options) }) - test('extracts adaptert from options to variable', () => { + test('extracts adapter from options to variable', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) @@ -129,7 +134,7 @@ describe('run', () => { }) afterEach(() => { - vi.resetAllMocks() + // vi.resetAllMocks() }) afterAll(() => { @@ -152,24 +157,22 @@ describe('run', () => { test('does nothing if no job found and forever=false', async () => { const adapter = { find: vi.fn(() => null) } - const mockExecutor = vi.fn() - vi.mock('../Executor', () => ({ Executor: mockExecutor })) + vi.spyOn(Executor, 'constructor') const worker = new Worker({ adapter, waitTime: 0, forever: false }) await worker.run() - expect(mockExecutor).not.toHaveBeenCalled() + expect(Executor).not.toHaveBeenCalled() }) test('does nothing if no job found and workoff=true', async () => { const adapter = { find: vi.fn(() => null) } - const mockExecutor = vi.fn() - vi.mock('../Executor', () => ({ Executor: mockExecutor })) + vi.spyOn(Executor, 'constructor') const worker = new Worker({ adapter, waitTime: 0, workoff: true }) await worker.run() - expect(mockExecutor).not.toHaveBeenCalled() + expect(Executor).not.toHaveBeenCalled() }) test('initializes an Executor instance if the job is found', async () => { From 54315c8fda30df08aa6fa9fa2613e8048eca8f19 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 28 Jun 2024 15:55:29 -0700 Subject: [PATCH 034/258] =?UTF-8?q?Don=E2=80=99t=20load=20ENV=20files=20in?= =?UTF-8?q?=20worker.js=20for=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/jobs/src/bins/worker.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/jobs/src/bins/worker.js b/packages/jobs/src/bins/worker.js index 4821c6d0756c..37b36827f01e 100755 --- a/packages/jobs/src/bins/worker.js +++ b/packages/jobs/src/bins/worker.js @@ -7,9 +7,6 @@ import process from 'node:process' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' -import { loadEnvFiles } from '@redwoodjs/cli/dist/lib/loadEnvFiles' - -loadEnvFiles() import { loadAdapter, loadLogger } from '../core/loaders' import { Worker } from '../core/Worker' From 328d3419766b667c9b04ac94b27bfbf268a7d570 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 28 Jun 2024 15:55:37 -0700 Subject: [PATCH 035/258] Placeholder tests --- packages/jobs/src/bins/__tests__/runner.test.js | 9 +++++++++ packages/jobs/src/bins/__tests__/worker.test.js | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/jobs/src/bins/__tests__/runner.test.js b/packages/jobs/src/bins/__tests__/runner.test.js index e69de29bb2d1..1b3d0f15b411 100644 --- a/packages/jobs/src/bins/__tests__/runner.test.js +++ b/packages/jobs/src/bins/__tests__/runner.test.js @@ -0,0 +1,9 @@ +import { describe, expect, vi, test } from 'vitest' + +// import * as runner from '../runner' + +describe('runner', () => { + test.skip('placeholder', () => { + expect(true).toBeTruthy() + }) +}) diff --git a/packages/jobs/src/bins/__tests__/worker.test.js b/packages/jobs/src/bins/__tests__/worker.test.js index e69de29bb2d1..36eb60048b53 100644 --- a/packages/jobs/src/bins/__tests__/worker.test.js +++ b/packages/jobs/src/bins/__tests__/worker.test.js @@ -0,0 +1,16 @@ +import path from 'node:path' + +import { describe, expect, vi, test } from 'vitest' + +// import * as worker from '../worker' + +// so that registerApiSideBabelHook() doesn't freak out about redwood.toml +vi.mock('@redwoodjs/babel-config') + +describe('worker', () => { + test('placeholder', () => { + console.info(process.env.RWJS_CWD) + + expect(true).toBeTruthy() + }) +}) From 0fddf26b3b721e09e0b8571c6ae42a0c311ee7ff Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 28 Jun 2024 15:55:45 -0700 Subject: [PATCH 036/258] Fix vitest watch --- packages/jobs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jobs/package.json b/packages/jobs/package.json index b11fcc9ea159..5a27232a3f8d 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -22,7 +22,7 @@ "build:watch": "nodemon --watch src --ext \"js,jsx,ts,tsx\" --ignore dist --exec \"yarn build\"", "prepublishOnly": "NODE_ENV=production yarn build", "test": "vitest run", - "test:watch": "yarn test --watch" + "test:watch": "vitest" }, "dependencies": { "fast-glob": "3.3.2" From 4c28c64cad6a3a26828b58fc87aad2fe4bc3b40c Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 28 Jun 2024 15:55:55 -0700 Subject: [PATCH 037/258] Remove scenarios file --- .../__tests__/PrismaAdapter.scenarios.js | 41 ------------------- 1 file changed, 41 deletions(-) delete mode 100644 packages/jobs/src/adapters/__tests__/PrismaAdapter.scenarios.js diff --git a/packages/jobs/src/adapters/__tests__/PrismaAdapter.scenarios.js b/packages/jobs/src/adapters/__tests__/PrismaAdapter.scenarios.js deleted file mode 100644 index ed7693ddb0c7..000000000000 --- a/packages/jobs/src/adapters/__tests__/PrismaAdapter.scenarios.js +++ /dev/null @@ -1,41 +0,0 @@ -// import { test, expect } from '@jest/globals' - -export const standard = defineScenario({ - backgroundJob: { - email: { - data: { - id: 1, - handler: JSON.stringify({ handler: 'EmailJob', args: [123] }), - queue: 'email', - priority: 50, - runAt: '2021-04-30T15:35:19Z', - }, - }, - - multipleAttempts: { - data: { - id: 2, - attempts: 10, - handler: JSON.stringify({ handler: 'TestJob', args: [123] }), - queue: 'default', - priority: 50, - runAt: '2021-04-30T15:35:19Z', - }, - }, - - maxAttempts: { - data: { - id: 3, - attempts: 24, - handler: JSON.stringify({ handler: 'TestJob', args: [123] }), - queue: 'default', - priority: 50, - runAt: '2021-04-30T15:35:19Z', - }, - }, - }, -}) - -// test('truth', () => { -// expect(true) -// }) From 745febe784124c8b8504f6919d3da45a127d41f8 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 28 Jun 2024 17:06:56 -0700 Subject: [PATCH 038/258] Fix PrismaAdapter tests --- .../adapters/__tests__/PrismaAdapter.test.js | 390 +++++++++++------- 1 file changed, 242 insertions(+), 148 deletions(-) diff --git a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js index 4efd09f44cf8..cb7e230686fe 100644 --- a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js +++ b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js @@ -1,7 +1,5 @@ import { describe, expect, vi, test, beforeEach, afterEach } from 'vitest' -import { db } from 'src/lib/db' - import * as errors from '../../core/errors' import { PrismaAdapter, @@ -11,30 +9,90 @@ import { vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) -describe('constructor', () => { - beforeEach(() => { - vi.clearAllMocks() - }) +// test data +// export const standard = defineScenario({ +// backgroundJob: { +// email: { +// data: { +// id: 1, +// handler: JSON.stringify({ handler: 'EmailJob', args: [123] }), +// queue: 'email', +// priority: 50, +// runAt: '2021-04-30T15:35:19Z', +// }, +// }, + +// multipleAttempts: { +// data: { +// id: 2, +// attempts: 10, +// handler: JSON.stringify({ handler: 'TestJob', args: [123] }), +// queue: 'default', +// priority: 50, +// runAt: '2021-04-30T15:35:19Z', +// }, +// }, + +// maxAttempts: { +// data: { +// id: 3, +// attempts: 24, +// handler: JSON.stringify({ handler: 'TestJob', args: [123] }), +// queue: 'default', +// priority: 50, +// runAt: '2021-04-30T15:35:19Z', +// }, +// }, +// }, +// }) + +// test('truth', () => { +// expect(true) +// }) + +let mockDb + +beforeEach(() => { + mockDb = { + _activeProvider: 'sqlite', + _runtimeDataModel: { + models: { + BackgroundJob: { + dbName: null, + }, + }, + }, + backgroundJob: { + delete: vi.fn(), + deleteMany: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + } +}) + +afterEach(() => { + vi.resetAllMocks() +}) +describe('constructor', () => { test('defaults this.model name', () => { - const adapter = new PrismaAdapter({ db }) + const adapter = new PrismaAdapter({ db: mockDb }) expect(adapter.model).toEqual(DEFAULT_MODEL_NAME) }) test('can manually set this.model', () => { - const dbMock = vi.fn(() => ({ - _runtimeDataModel: { - models: { - Job: { - dbName: null, - }, - }, + mockDb._runtimeDataModel.models = { + Job: { + dbName: null, }, - job: {}, - })) + } + mockDb.job = {} + const adapter = new PrismaAdapter({ - db: dbMock(), + db: mockDb, model: 'Job', }) @@ -42,72 +100,67 @@ describe('constructor', () => { }) test('throws an error with a model name that does not exist', () => { - expect(() => new PrismaAdapter({ db, model: 'FooBar' })).toThrow( + expect(() => new PrismaAdapter({ db: mockDb, model: 'FooBar' })).toThrow( errors.ModelNameError, ) }) test('sets this.accessor to the correct Prisma accessor', () => { - const adapter = new PrismaAdapter({ db }) + const adapter = new PrismaAdapter({ db: mockDb }) - expect(adapter.accessor).toEqual(db.backgroundJob) + expect(adapter.accessor).toEqual(mockDb.backgroundJob) }) test('manually set this.tableName ', () => { - const adapter = new PrismaAdapter({ db, tableName: 'background_jobz' }) + const adapter = new PrismaAdapter({ + db: mockDb, + tableName: 'background_jobz', + }) expect(adapter.tableName).toEqual('background_jobz') }) test('set this.tableName from custom @@map() name in schema', () => { - const dbMock = vi.fn(() => ({ - _runtimeDataModel: { - models: { - BackgroundJob: { - dbName: 'bg_jobs', - }, - }, - }, - })) + mockDb._runtimeDataModel.models.BackgroundJob.dbName = 'bg_jobs' const adapter = new PrismaAdapter({ - db: dbMock(), + db: mockDb, }) expect(adapter.tableName).toEqual('bg_jobs') }) test('default this.tableName to camelCase version of model name', () => { - const adapter = new PrismaAdapter({ db }) + const adapter = new PrismaAdapter({ db: mockDb }) expect(adapter.tableName).toEqual('BackgroundJob') }) test('sets this.provider based on the active provider', () => { - const adapter = new PrismaAdapter({ db }) + const adapter = new PrismaAdapter({ db: mockDb }) expect(adapter.provider).toEqual('sqlite') }) test('defaults this.maxAttempts', () => { - const adapter = new PrismaAdapter({ db }) + const adapter = new PrismaAdapter({ db: mockDb }) expect(adapter.maxAttempts).toEqual(DEFAULT_MAX_ATTEMPTS) }) test('can manually set this.maxAttempts', () => { - const adapter = new PrismaAdapter({ db, maxAttempts: 10 }) + const adapter = new PrismaAdapter({ db: mockDb, maxAttempts: 10 }) expect(adapter.maxAttempts).toEqual(10) }) }) -describe('schedule()', () => { +describe.skip('schedule()', () => { afterEach(async () => { await db.backgroundJob.deleteMany() }) test('creates a job in the DB', async () => { - const adapter = new PrismaAdapter({ db }) + const adapter = new PrismaAdapter({ db: mockDb }) const beforeJobCount = await db.backgroundJob.count() await adapter.schedule({ handler: 'RedwoodJob', @@ -122,7 +175,7 @@ describe('schedule()', () => { }) test('returns the job record that was created', async () => { - const adapter = new PrismaAdapter({ db }) + const adapter = new PrismaAdapter({ db: mockDb }) const job = await adapter.schedule({ handler: 'RedwoodJob', args: ['foo', 'bar'], @@ -138,7 +191,7 @@ describe('schedule()', () => { }) test('makes no attempt to de-dupe jobs', async () => { - const adapter = new PrismaAdapter({ db }) + const adapter = new PrismaAdapter({ db: mockDb }) const job1 = await adapter.schedule({ handler: 'RedwoodJob', args: ['foo', 'bar'], @@ -163,7 +216,7 @@ describe('schedule()', () => { }) test('defaults some database fields', async () => { - const adapter = new PrismaAdapter({ db }) + const adapter = new PrismaAdapter({ db: mockDb }) const job = await adapter.schedule({ handler: 'RedwoodJob', args: ['foo', 'bar'], @@ -183,167 +236,208 @@ describe('schedule()', () => { describe('find()', () => { // TODO add more tests for all the various WHERE conditions when finding a job - scenario('returns null if no job found', async () => { - const adapter = new PrismaAdapter({ db }) + test('returns null if no job found', async () => { + vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(null) + const adapter = new PrismaAdapter({ db: mockDb }) const job = await adapter.find({ processName: 'test', maxRuntime: 1000, queue: 'foobar', }) + expect(job).toBeNull() }) - scenario('returns a job if conditions met', async (scenario) => { - const adapter = new PrismaAdapter({ db }) + test('returns a job if found', async () => { + const mockJob = { id: 1 } + vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) + vi.spyOn(mockDb.backgroundJob, 'updateMany').mockReturnValue({ count: 1 }) + const adapter = new PrismaAdapter({ db: mockDb }) const job = await adapter.find({ processName: 'test', maxRuntime: 1000, - queue: scenario.backgroundJob.email.queue, + queue: 'default', }) - expect(job.id).toEqual(scenario.backgroundJob.email.id) + + expect(job).toEqual(mockJob) }) - scenario( - 'increments the `attempts` count on the found job', - async (scenario) => { - const adapter = new PrismaAdapter({ db }) - const job = await adapter.find({ - processName: 'test', - maxRuntime: 1000, - queue: scenario.backgroundJob.email.queue, - }) - expect(job.attempts).toEqual(scenario.backgroundJob.email.attempts + 1) - }, - ) + test('increments the `attempts` count on the found job', async () => { + const mockJob = { id: 1, attempts: 0 } + vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) + const updateSpy = vi + .spyOn(mockDb.backgroundJob, 'updateMany') + .mockReturnValue({ count: 1 }) + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.find({ + processName: 'test', + maxRuntime: 1000, + queue: 'default', + }) - scenario('locks the job for the current process', async (scenario) => { - const adapter = new PrismaAdapter({ db }) - const job = await adapter.find({ + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ attempts: 1 }), + }), + ) + }) + + test('locks the job for the current process', async () => { + const mockJob = { id: 1, attempts: 0 } + vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) + const updateSpy = vi + .spyOn(mockDb.backgroundJob, 'updateMany') + .mockReturnValue({ count: 1 }) + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.find({ processName: 'test-process', maxRuntime: 1000, - queue: scenario.backgroundJob.email.queue, + queue: 'default', }) - expect(job.lockedBy).toEqual('test-process') + + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ lockedBy: 'test-process' }), + }), + ) }) - scenario('locks the job with a current timestamp', async (scenario) => { - const adapter = new PrismaAdapter({ db }) - const job = await adapter.find({ + test('locks the job with a current timestamp', async () => { + const mockJob = { id: 1, attempts: 0 } + vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) + const updateSpy = vi + .spyOn(mockDb.backgroundJob, 'updateMany') + .mockReturnValue({ count: 1 }) + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.find({ processName: 'test-process', maxRuntime: 1000, - queue: scenario.backgroundJob.email.queue, + queue: 'default', }) - expect(job.lockedAt).toEqual(new Date()) + + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ lockedAt: new Date() }), + }), + ) }) }) describe('success()', () => { - scenario('deletes the job from the DB', async (scenario) => { - const adapter = new PrismaAdapter({ db }) - const job = await adapter.success(scenario.backgroundJob.email) - const dbJob = await db.backgroundJob.findFirst({ - where: { id: job.id }, - }) + test('deletes the job from the DB', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'delete') + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.success({ id: 1 }) - expect(dbJob).toBeNull() + expect(spy).toHaveBeenCalledWith({ where: { id: 1 } }) }) }) describe('failure()', () => { - scenario('clears the lock fields', async (scenario) => { - const adapter = new PrismaAdapter({ db }) - await adapter.failure( - scenario.backgroundJob.multipleAttempts, - new Error('test error'), - ) - const dbJob = await db.backgroundJob.findFirst({ - where: { id: scenario.backgroundJob.multipleAttempts.id }, - }) + test('updates the job by id', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'update') + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.failure({ id: 1 }, new Error('test error')) - expect(dbJob.lockedAt).toBeNull() - expect(dbJob.lockedBy).toBeNull() + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 1 } }), + ) }) - scenario( - 'reschedules the job at a designated backoff time', - async (scenario) => { - const adapter = new PrismaAdapter({ db }) - await adapter.failure( - scenario.backgroundJob.multipleAttempts, - new Error('test error'), - ) - const dbJob = await db.backgroundJob.findFirst({ - where: { id: scenario.backgroundJob.multipleAttempts.id }, - }) - - expect(dbJob.runAt).toEqual( - new Date( - new Date().getTime() + - 1000 * scenario.backgroundJob.multipleAttempts.attempts ** 4, - ), - ) - }, - ) + test('clears the lock fields', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'update') + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.failure({ id: 1 }, new Error('test error')) - scenario('records the error', async (scenario) => { - const adapter = new PrismaAdapter({ db }) - await adapter.failure( - scenario.backgroundJob.multipleAttempts, - new Error('test error'), + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ lockedAt: null, lockedBy: null }), + }), ) - const dbJob = await db.backgroundJob.findFirst({ - where: { id: scenario.backgroundJob.multipleAttempts.id }, - }) + }) - expect(dbJob.lastError).toContain('test error\n\n') + test('reschedules the job at a designated backoff time', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'update') + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.failure({ id: 1, attempts: 10 }, new Error('test error')) + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + runAt: new Date(new Date().getTime() + 1000 * 10 ** 4), + }), + }), + ) }) - scenario( - 'marks the job as failed if max attempts reached', - async (scenario) => { - const adapter = new PrismaAdapter({ db }) - await adapter.failure( - scenario.backgroundJob.maxAttempts, - new Error('test error'), - ) - const dbJob = await db.backgroundJob.findFirst({ - where: { id: scenario.backgroundJob.maxAttempts.id }, - }) - - expect(dbJob.failedAt).toEqual(new Date()) - }, - ) + test('records the error', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'update') + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.failure({ id: 1, attempts: 10 }, new Error('test error')) + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + lastError: expect.stringContaining('test error'), + }), + }), + ) + }) - scenario('nullifies runtAt if max attempts reached', async (scenario) => { - const adapter = new PrismaAdapter({ db }) - await adapter.failure( - scenario.backgroundJob.maxAttempts, - new Error('test error'), + test('marks the job as failed if max attempts reached', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'update') + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.failure({ id: 1, attempts: 24 }, new Error('test error')) + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + failedAt: new Date(), + }), + }), ) - const dbJob = await db.backgroundJob.findFirst({ - where: { id: scenario.backgroundJob.maxAttempts.id }, - }) + }) - expect(dbJob.runAt).toBeNull() + test('nullifies runtAt if max attempts reached', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'update') + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.failure({ id: 1, attempts: 24 }, new Error('test error')) + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + runAt: null, + }), + }), + ) }) }) describe('clear()', () => { - scenario('deletes all jobs from the DB', async () => { - const adapter = new PrismaAdapter({ db }) + test('deletes all jobs from the DB', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'deleteMany') + + const adapter = new PrismaAdapter({ db: mockDb }) await adapter.clear() - const jobCount = await db.backgroundJob.count() - expect(jobCount).toEqual(0) + expect(spy).toHaveBeenCalledOnce() }) }) describe('backoffMilliseconds()', () => { test('returns the number of milliseconds to wait for the next run', () => { - expect(new PrismaAdapter({ db }).backoffMilliseconds(0)).toEqual(0) - expect(new PrismaAdapter({ db }).backoffMilliseconds(1)).toEqual(1000) - expect(new PrismaAdapter({ db }).backoffMilliseconds(2)).toEqual(16000) - expect(new PrismaAdapter({ db }).backoffMilliseconds(3)).toEqual(81000) - expect(new PrismaAdapter({ db }).backoffMilliseconds(20)).toEqual(160000000) + expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(0)).toEqual(0) + expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(1)).toEqual( + 1000, + ) + expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(2)).toEqual( + 16000, + ) + expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(3)).toEqual( + 81000, + ) + expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(20)).toEqual( + 160000000, + ) }) }) From 88a7c417c4e8633000eec3b4fede29c825b1768a Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 1 Jul 2024 09:59:30 -0700 Subject: [PATCH 039/258] Finishes PrismaAdapter.schedule() tests --- .../adapters/__tests__/PrismaAdapter.test.js | 125 +++--------------- 1 file changed, 16 insertions(+), 109 deletions(-) diff --git a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js index cb7e230686fe..8f5530008ec2 100644 --- a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js +++ b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js @@ -9,47 +9,6 @@ import { vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) -// test data -// export const standard = defineScenario({ -// backgroundJob: { -// email: { -// data: { -// id: 1, -// handler: JSON.stringify({ handler: 'EmailJob', args: [123] }), -// queue: 'email', -// priority: 50, -// runAt: '2021-04-30T15:35:19Z', -// }, -// }, - -// multipleAttempts: { -// data: { -// id: 2, -// attempts: 10, -// handler: JSON.stringify({ handler: 'TestJob', args: [123] }), -// queue: 'default', -// priority: 50, -// runAt: '2021-04-30T15:35:19Z', -// }, -// }, - -// maxAttempts: { -// data: { -// id: 3, -// attempts: 24, -// handler: JSON.stringify({ handler: 'TestJob', args: [123] }), -// queue: 'default', -// priority: 50, -// runAt: '2021-04-30T15:35:19Z', -// }, -// }, -// }, -// }) - -// test('truth', () => { -// expect(true) -// }) - let mockDb beforeEach(() => { @@ -63,6 +22,7 @@ beforeEach(() => { }, }, backgroundJob: { + create: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(), findFirst: vi.fn(), @@ -154,14 +114,12 @@ describe('constructor', () => { }) }) -describe.skip('schedule()', () => { - afterEach(async () => { - await db.backgroundJob.deleteMany() - }) - - test('creates a job in the DB', async () => { +describe('schedule()', () => { + test('creates a job in the DB with required data', async () => { + const createSpy = vi + .spyOn(mockDb.backgroundJob, 'create') + .mockReturnValue({ id: 1 }) const adapter = new PrismaAdapter({ db: mockDb }) - const beforeJobCount = await db.backgroundJob.count() await adapter.schedule({ handler: 'RedwoodJob', args: ['foo', 'bar'], @@ -169,73 +127,22 @@ describe.skip('schedule()', () => { priority: 50, runAt: new Date(), }) - const afterJobCount = await db.backgroundJob.count() - expect(afterJobCount).toEqual(beforeJobCount + 1) - }) - - test('returns the job record that was created', async () => { - const adapter = new PrismaAdapter({ db: mockDb }) - const job = await adapter.schedule({ - handler: 'RedwoodJob', - args: ['foo', 'bar'], - queue: 'default', - priority: 50, - runAt: new Date(), - }) - - expect(job.handler).toEqual('{"handler":"RedwoodJob","args":["foo","bar"]}') - expect(job.runAt).toEqual(new Date()) - expect(job.queue).toEqual('default') - expect(job.priority).toEqual(50) - }) - - test('makes no attempt to de-dupe jobs', async () => { - const adapter = new PrismaAdapter({ db: mockDb }) - const job1 = await adapter.schedule({ - handler: 'RedwoodJob', - args: ['foo', 'bar'], - queue: 'default', - priority: 50, - runAt: new Date(), - }) - const job2 = await adapter.schedule({ - handler: 'RedwoodJob', - args: ['foo', 'bar'], - queue: 'default', - priority: 50, - runAt: new Date(), - }) - - // definitely a different record in the DB - expect(job1.id).not.toEqual(job2.id) - // but all details are identical - expect(job1.handler).toEqual(job2.handler) - expect(job1.queue).toEqual(job2.queue) - expect(job1.priority).toEqual(job2.priority) - }) - - test('defaults some database fields', async () => { - const adapter = new PrismaAdapter({ db: mockDb }) - const job = await adapter.schedule({ - handler: 'RedwoodJob', - args: ['foo', 'bar'], - queue: 'default', - priority: 50, - runAt: new Date(), + expect(createSpy).toHaveBeenCalledWith({ + data: { + handler: JSON.stringify({ + handler: 'RedwoodJob', + args: ['foo', 'bar'], + }), + priority: 50, + queue: 'default', + runAt: new Date(), + }, }) - - expect(job.attempts).toEqual(0) - expect(job.lockedAt).toBeNull() - expect(job.lockedBy).toBeNull() - expect(job.lastError).toBeNull() - expect(job.failedAt).toBeNull() }) }) describe('find()', () => { - // TODO add more tests for all the various WHERE conditions when finding a job - test('returns null if no job found', async () => { vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(null) const adapter = new PrismaAdapter({ db: mockDb }) From f2d83d46eaa82f9005e3f7bd754656e6055b1f09 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 2 Jul 2024 14:19:04 -0700 Subject: [PATCH 040/258] Refactor .find() for just the single default query --- packages/jobs/src/adapters/PrismaAdapter.js | 147 ++++++++++---------- 1 file changed, 77 insertions(+), 70 deletions(-) diff --git a/packages/jobs/src/adapters/PrismaAdapter.js b/packages/jobs/src/adapters/PrismaAdapter.js index c30711399745..6f0d4754800d 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.js +++ b/packages/jobs/src/adapters/PrismaAdapter.js @@ -76,11 +76,84 @@ export class PrismaAdapter extends BaseAdapter { // The act of locking a job is dependant on the DB server, so we'll run some // raw SQL to do it in each case—Prisma doesn't provide enough flexibility // in their generated code to do this in a DB-agnostic way. - find(options) { - switch (this.options.db._activeProvider) { - case 'sqlite': - return this.#sqliteFind(options) + // TODO there may be more optimzed versions of the locking queries in Postgres and MySQL, this.options.db._activeProvider returns the provider name + async find({ processName, maxRuntime, queue }) { + const maxRuntimeExpire = new Date(new Date() - maxRuntime) + + // This query is gnarly but not so bad once you know what it's doing. For a + // job to match it must: + // - have a runtAt in the past + // - is either not locked, or was locked more than `maxRuntime` ago, + // or was already locked by this exact process and never cleaned up + // - doesn't have a failedAt, meaning we will stop retrying + // Translates to: + // `((runAt <= ? AND (lockedAt IS NULL OR lockedAt < ?)) OR lockedBy = ?) AND failedAt IS NULL` + const where = { + AND: [ + { + OR: [ + { + AND: [ + { runAt: { lte: new Date() } }, + { + OR: [ + { lockedAt: null }, + { + lockedAt: { + lt: maxRuntimeExpire, + }, + }, + ], + }, + ], + }, + { lockedBy: processName }, + ], + }, + { failedAt: null }, + ], + } + + // for some reason prisma doesn't like it's own `query: { not: null }` + // syntax, so only add the query condition if we're filtering by queue + const whereWithQueue = Object.assign(where, { + AND: [...where.AND, { queue: queue || undefined }], + }) + + // Find the next job that should run now + let job = await this.accessor.findFirst({ + select: { id: true, attempts: true }, + where: whereWithQueue, + orderBy: [{ priority: 'asc' }, { runAt: 'asc' }], + take: 1, + }) + + if (job) { + // If one was found, try to lock it by updating the record with the + // same WHERE clause as above (if another locked in the meantime it won't + // find any record to update) + const whereWithQueueAndId = Object.assign(whereWithQueue, { + AND: [...whereWithQueue.AND, { id: job.id }], + }) + + const { count } = await this.accessor.updateMany({ + where: whereWithQueueAndId, + data: { + lockedAt: new Date(), + lockedBy: processName, + attempts: job.attempts + 1, + }, + }) + + // Assuming the update worked, return the full details of the job + if (count) { + return this.accessor.findFirst({ where: { id: job.id } }) + } } + + // If we get here then there were either no jobs, or the one we found + // was locked by another worker + return null } success(job) { @@ -131,70 +204,4 @@ export class PrismaAdapter extends BaseAdapter { backoffMilliseconds(attempts) { return 1000 * attempts ** 4 } - - async #sqliteFind({ processName, maxRuntime, queue }) { - const maxRuntimeExpire = new Date(new Date() - maxRuntime) - - // This query is gnarly but not so bad once you know what it's doing. For a - // job to match it must: - // - have a runtAt in the past - // - is either not locked, or was locked more than `maxRuntime` ago, - // or was already locked by this exact process and never cleaned up - // - and doesn't have a failedAt, meaning we will stop retrying - const prismaWhere = { - AND: [ - { runAt: { lte: new Date() } }, - { - OR: [ - { lockedAt: null }, - { - lockedAt: { - lt: maxRuntimeExpire, - }, - }, - { lockedBy: processName }, - ], - }, - { failedAt: null }, - ], - } - - // for some reason prisma doesn't like it's own `query: { not: null }` - // syntax, so only add the query condition if we're filtering by queue - if (queue) { - prismaWhere.AND.unshift({ queue }) - } - - // Find the next job that should run now - let job = await this.accessor.findFirst({ - select: { id: true, attempts: true }, - where: prismaWhere, - orderBy: [{ priority: 'asc' }, { runAt: 'asc' }], - take: 1, - }) - - if (job) { - // If one was found, try to lock it by updating the record with the - // same WHERE clause as above (if another locked in the meantime it won't - // find any record to update) - prismaWhere.AND.push({ id: job.id }) - const { count } = await this.accessor.updateMany({ - where: prismaWhere, - data: { - lockedAt: new Date(), - lockedBy: processName, - attempts: job.attempts + 1, - }, - }) - - // Assuming the update worked, return the job - if (count) { - return this.accessor.findFirst({ where: { id: job.id } }) - } - } - - // If we get here then there were either no jobs, or the one we found - // was locked by another worker - return null - } } From ae59600e7767da3a02c5ec21677379b1c79f200e Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 2 Jul 2024 14:38:15 -0700 Subject: [PATCH 041/258] Rename bins --- packages/jobs/package.json | 3 ++- .../__tests__/{worker.test.js => rw-jobs-worker.test.js} | 0 .../src/bins/__tests__/{runner.test.js => rw-jobs.test.js} | 0 packages/jobs/src/bins/{worker.js => rw-jobs-worker.js} | 2 +- packages/jobs/src/bins/{runner.js => rw-jobs.js} | 6 +++--- 5 files changed, 6 insertions(+), 5 deletions(-) rename packages/jobs/src/bins/__tests__/{worker.test.js => rw-jobs-worker.test.js} (100%) rename packages/jobs/src/bins/__tests__/{runner.test.js => rw-jobs.test.js} (100%) rename packages/jobs/src/bins/{worker.js => rw-jobs-worker.js} (98%) rename packages/jobs/src/bins/{runner.js => rw-jobs.js} (97%) diff --git a/packages/jobs/package.json b/packages/jobs/package.json index 5a27232a3f8d..8367c05f6a7f 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -13,7 +13,8 @@ "dist" ], "bin": { - "rw-jobs": "./dist/bins/runner.js" + "rw-jobs": "./dist/bins/rw-jobs.js", + "rw-jobs-worker": "./dist/bins/rw-jobs-worker.js" }, "scripts": { "build": "tsx ./build.mts && yarn build:types", diff --git a/packages/jobs/src/bins/__tests__/worker.test.js b/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js similarity index 100% rename from packages/jobs/src/bins/__tests__/worker.test.js rename to packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js diff --git a/packages/jobs/src/bins/__tests__/runner.test.js b/packages/jobs/src/bins/__tests__/rw-jobs.test.js similarity index 100% rename from packages/jobs/src/bins/__tests__/runner.test.js rename to packages/jobs/src/bins/__tests__/rw-jobs.test.js diff --git a/packages/jobs/src/bins/worker.js b/packages/jobs/src/bins/rw-jobs-worker.js similarity index 98% rename from packages/jobs/src/bins/worker.js rename to packages/jobs/src/bins/rw-jobs-worker.js index 37b36827f01e..47b256347bcd 100755 --- a/packages/jobs/src/bins/worker.js +++ b/packages/jobs/src/bins/rw-jobs-worker.js @@ -10,7 +10,7 @@ import yargs from 'yargs/yargs' import { loadAdapter, loadLogger } from '../core/loaders' import { Worker } from '../core/Worker' -const TITLE_PREFIX = `rw-job-worker` +const TITLE_PREFIX = `rw-jobs-worker` const parseArgs = (argv) => { return yargs(hideBin(argv)) diff --git a/packages/jobs/src/bins/runner.js b/packages/jobs/src/bins/rw-jobs.js similarity index 97% rename from packages/jobs/src/bins/runner.js rename to packages/jobs/src/bins/rw-jobs.js index 9877ee0633ec..6fdf99e7fb71 100755 --- a/packages/jobs/src/bins/runner.js +++ b/packages/jobs/src/bins/rw-jobs.js @@ -17,7 +17,7 @@ import { loadLogger } from '../core/loaders' loadEnvFiles() -process.title = 'rw-job-runner' +process.title = 'rw-jobs' const parseArgs = (argv) => { const parsed = yargs(hideBin(argv)) @@ -132,7 +132,7 @@ const startWorkers = ({ // fork the worker process // TODO squiggles under __dirname, but import.meta.dirname blows up when running the process - const worker = fork(path.join(__dirname, 'worker.js'), workerArgs, { + const worker = fork(path.join(__dirname, 'rw-jobs-worker.js'), workerArgs, { detached: detach, stdio: detach ? 'ignore' : 'inherit', }) @@ -214,7 +214,7 @@ const stopWorkers = async ({ workerConfig, signal = 'SIGINT', logger }) => { ) for (const [queue, id] of workerConfig) { - const workerTitle = `rw-job-worker${queue ? `.${queue}` : ''}.${id}` + const workerTitle = `rw-jobs-worker${queue ? `.${queue}` : ''}.${id}` const processId = await findProcessId(workerTitle) if (!processId) { From f9073ee6be6805cdf22b659791586bf30bc49566 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 2 Jul 2024 20:23:43 -0700 Subject: [PATCH 042/258] Remove unneeded awaits --- packages/jobs/src/adapters/PrismaAdapter.js | 4 ++-- packages/jobs/src/core/RedwoodJob.js | 8 ++++---- packages/jobs/src/core/__tests__/RedwoodJob.test.js | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/jobs/src/adapters/PrismaAdapter.js b/packages/jobs/src/adapters/PrismaAdapter.js index 6f0d4754800d..5ea801f75f5c 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.js +++ b/packages/jobs/src/adapters/PrismaAdapter.js @@ -161,7 +161,7 @@ export class PrismaAdapter extends BaseAdapter { return this.accessor.delete({ where: { id: job.id } }) } - async failure(job, error) { + failure(job, error) { this.logger.debug(`Job ${job.id} failure`) const data = { lockedAt: null, @@ -178,7 +178,7 @@ export class PrismaAdapter extends BaseAdapter { ) } - return await this.accessor.update({ + return this.accessor.update({ where: { id: job.id }, data, }) diff --git a/packages/jobs/src/core/RedwoodJob.js b/packages/jobs/src/core/RedwoodJob.js index eb0e5f89894e..673fca2dcd26 100644 --- a/packages/jobs/src/core/RedwoodJob.js +++ b/packages/jobs/src/core/RedwoodJob.js @@ -86,14 +86,14 @@ export class RedwoodJob { // Instance method to runs the job immediately in the current process // const result = RedwoodJob.performNow('foo', 'bar') - async performNow(...args) { + performNow(...args) { this.logger.info( this.payload(args), `[RedwoodJob] Running ${this.constructor.name} now`, ) try { - return await this.perform(...args) + return this.perform(...args) } catch (e) { if (e instanceof PerformNotImplementedError) { throw e @@ -182,13 +182,13 @@ export class RedwoodJob { // Private, schedules a job with the appropriate adapter, returns whatever // the adapter returns in response to a successful schedule. - async #schedule(args) { + #schedule(args) { if (!this.constructor.adapter) { throw new AdapterNotConfiguredError() } try { - return await this.constructor.adapter.schedule(this.payload(args)) + return this.constructor.adapter.schedule(this.payload(args)) } catch (e) { throw new SchedulingError( `[RedwoodJob] Exception when scheduling ${this.constructor.name}`, diff --git a/packages/jobs/src/core/__tests__/RedwoodJob.test.js b/packages/jobs/src/core/__tests__/RedwoodJob.test.js index 16cac1d47fa6..0c2604a69589 100644 --- a/packages/jobs/src/core/__tests__/RedwoodJob.test.js +++ b/packages/jobs/src/core/__tests__/RedwoodJob.test.js @@ -279,7 +279,7 @@ describe('instance performLater()', () => { const job = new RedwoodJob() - await expect(job.performLater('foo', 'bar')).rejects.toThrow( + expect(() => job.performLater('foo', 'bar')).toThrow( errors.AdapterNotConfiguredError, ) }) @@ -402,7 +402,7 @@ describe('instance performNow()', () => { class TestJob extends RedwoodJob {} const job = new TestJob() - await expect(job.performNow('foo', 'bar')).rejects.toThrow( + expect(() => job.performNow('foo', 'bar')).toThrow( errors.PerformNotImplementedError, ) }) @@ -461,7 +461,7 @@ describe('instance performNow()', () => { test('catches any errors thrown during perform and throws custom error', async () => { class TestJob extends RedwoodJob { - async perform() { + perform() { throw new Error('Could not perform') } } @@ -473,7 +473,7 @@ describe('instance performNow()', () => { RedwoodJob.config({ adapter: mockAdapter }) try { - await new TestJob().performNow('foo', 'bar') + new TestJob().performNow('foo', 'bar') } catch (e) { expect(e).toBeInstanceOf(errors.PerformError) expect(e.message).toEqual('[TestJob] exception when running job') From 6693d5f4186bfffdc88eef082480f3d734d6839d Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 3 Jul 2024 15:21:35 -0700 Subject: [PATCH 043/258] Convert loaders to TS --- .../jobs/src/core/{loaders.js => loaders.ts} | 28 +++++++++++-------- packages/project-config/src/paths.ts | 10 ++++--- 2 files changed, 23 insertions(+), 15 deletions(-) rename packages/jobs/src/core/{loaders.js => loaders.ts} (70%) diff --git a/packages/jobs/src/core/loaders.js b/packages/jobs/src/core/loaders.ts similarity index 70% rename from packages/jobs/src/core/loaders.js rename to packages/jobs/src/core/loaders.ts index 8913aa9eacf0..424b67e243c7 100644 --- a/packages/jobs/src/core/loaders.js +++ b/packages/jobs/src/core/loaders.ts @@ -16,15 +16,15 @@ import { // TODO Don't use this in production, import from dist directly registerApiSideBabelHook() -export function makeFilePath(path) { +export function makeFilePath(path: string) { return pathToFileURL(path).href } // Loads the exported adapter from the app's jobs config in api/src/lib/jobs.js export const loadAdapter = async () => { - if (getPaths().api.jobs) { + if (getPaths().api.jobsConfig) { const { default: jobsModule } = await import( - makeFilePath(getPaths().api.jobsConfig) + makeFilePath(getPaths().api.jobsConfig as string) ) if (jobsModule.adapter) { return jobsModule.adapter @@ -38,18 +38,24 @@ export const loadAdapter = async () => { // Loads the logger from the app's filesystem in api/src/lib/logger.js export const loadLogger = async () => { - try { - const { default: loggerModule } = await import( - makeFilePath(getPaths().api.logger) - ) - return loggerModule.logger - } catch (e) { - return console + if (getPaths().api.logger) { + try { + const { default: loggerModule } = await import( + makeFilePath(getPaths().api.logger as string) + ) + return loggerModule.logger + } catch (e) { + console.warn( + 'Tried to load logger but failed, falling back to console', + e, + ) + } } + return console } // Loads a job from the app's filesystem in api/src/jobs -export const loadJob = async (name) => { +export const loadJob = async (name: string) => { const files = fg.sync(`**/${name}.*`, { cwd: getPaths().api.jobs }) if (!files[0]) { throw new JobNotFoundError(name) diff --git a/packages/project-config/src/paths.ts b/packages/project-config/src/paths.ts index 420690a0ca7b..07ce2106c563 100644 --- a/packages/project-config/src/paths.ts +++ b/packages/project-config/src/paths.ts @@ -23,6 +23,9 @@ export interface NodeTargetPaths { types: string models: string mail: string + jobs: string + jobsConfig: string | null + logger: string | null } export interface WebPaths { @@ -200,10 +203,7 @@ export const getPaths = (BASE_DIR: string = getBaseDir()): Paths => { dbSchema: path.join(BASE_DIR, schemaPath), functions: path.join(BASE_DIR, PATH_API_DIR_FUNCTIONS), graphql: path.join(BASE_DIR, PATH_API_DIR_GRAPHQL), - jobs: path.join(path.join(BASE_DIR, PATH_API_DIR_JOBS)), - jobsConfig: resolveFile(path.join(BASE_DIR, PATH_API_DIR_LIB, 'jobs')), lib: path.join(BASE_DIR, PATH_API_DIR_LIB), - logger: resolveFile(path.join(BASE_DIR, PATH_API_DIR_LIB, 'logger')), generators: path.join(BASE_DIR, PATH_API_DIR_GENERATORS), config: path.join(BASE_DIR, PATH_API_DIR_CONFIG), services: path.join(BASE_DIR, PATH_API_DIR_SERVICES), @@ -211,10 +211,12 @@ export const getPaths = (BASE_DIR: string = getBaseDir()): Paths => { subscriptions: path.join(BASE_DIR, PATH_API_DIR_SUBSCRIPTIONS), src: path.join(BASE_DIR, PATH_API_DIR_SRC), dist: path.join(BASE_DIR, 'api/dist'), - // TODO Add dist paths for logger and jobs types: path.join(BASE_DIR, 'api/types'), models: path.join(BASE_DIR, PATH_API_DIR_MODELS), mail: path.join(BASE_DIR, PATH_API_DIR_SRC, 'mail'), + jobs: path.join(path.join(BASE_DIR, PATH_API_DIR_JOBS)), + jobsConfig: resolveFile(path.join(BASE_DIR, PATH_API_DIR_LIB, 'jobs')), + logger: resolveFile(path.join(BASE_DIR, PATH_API_DIR_LIB, 'logger')), }, web: { From c4c822fbbd41c2742e09ce7894140d0e408792a8 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 3 Jul 2024 16:24:08 -0700 Subject: [PATCH 044/258] Add TS to BaseAdapter, add separate types file for shared types --- .../{BaseAdapter.js => BaseAdapter.ts} | 29 +++++++------------ packages/jobs/src/types.ts | 21 ++++++++++++++ 2 files changed, 32 insertions(+), 18 deletions(-) rename packages/jobs/src/adapters/{BaseAdapter.js => BaseAdapter.ts} (53%) create mode 100644 packages/jobs/src/types.ts diff --git a/packages/jobs/src/adapters/BaseAdapter.js b/packages/jobs/src/adapters/BaseAdapter.ts similarity index 53% rename from packages/jobs/src/adapters/BaseAdapter.js rename to packages/jobs/src/adapters/BaseAdapter.ts index 1a90fa90eeb5..b8cd5b167117 100644 --- a/packages/jobs/src/adapters/BaseAdapter.js +++ b/packages/jobs/src/adapters/BaseAdapter.ts @@ -7,31 +7,24 @@ import console from 'node:console' -import { NotImplementedError } from '../core/errors' +import type { BasicLogger, BaseJob, SchedulePayload } from '../types' -export class BaseAdapter { - constructor(options) { +export abstract class BaseAdapter { + options: any + logger: BasicLogger + + constructor(options: { logger?: BasicLogger }) { this.options = options this.logger = options?.logger || console } - schedule() { - throw new NotImplementedError('schedule') - } + abstract schedule(payload: SchedulePayload): void - find() { - throw new NotImplementedError('find') - } + abstract find(): BaseJob | null - clear() { - throw new NotImplementedError('clear') - } + abstract clear(): void - success() { - throw new NotImplementedError('success') - } + abstract success(job: BaseJob): void - failure() { - throw new NotImplementedError('failure') - } + abstract failure(job: BaseJob, error: typeof Error): void } diff --git a/packages/jobs/src/types.ts b/packages/jobs/src/types.ts new file mode 100644 index 000000000000..1b1061861e50 --- /dev/null +++ b/packages/jobs/src/types.ts @@ -0,0 +1,21 @@ +export interface BasicLogger { + debug: (message?: any, ...optionalParams: any[]) => void + info: (message?: any, ...optionalParams: any[]) => void + warn: (message?: any, ...optionalParams: any[]) => void + error: (message?: any, ...optionalParams: any[]) => void +} + +// Arguments sent to an adapter to schedule a job +export interface SchedulePayload { + handler: string + args: any + runAt: Date + queue: string + priority: number +} + +// Arguments returned from an adapter when a job is found +export interface BaseJob { + handler: string + args: any +} From cccf759582580f04401bd0049907d899e63870a9 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 3 Jul 2024 16:24:14 -0700 Subject: [PATCH 045/258] Add TS to Executor --- .../src/core/{Executor.js => Executor.ts} | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) rename packages/jobs/src/core/{Executor.js => Executor.ts} (69%) diff --git a/packages/jobs/src/core/Executor.js b/packages/jobs/src/core/Executor.ts similarity index 69% rename from packages/jobs/src/core/Executor.js rename to packages/jobs/src/core/Executor.ts index f73c3f894df0..673232c85c2e 100644 --- a/packages/jobs/src/core/Executor.js +++ b/packages/jobs/src/core/Executor.ts @@ -2,6 +2,9 @@ import console from 'node:console' +import type { BaseAdapter } from '../adapters/BaseAdapter' +import type { BasicLogger } from '../types' + import { AdapterRequiredError, JobRequiredError, @@ -9,12 +12,23 @@ import { } from './errors' import { loadJob } from './loaders' +interface Options { + adapter: BaseAdapter + job: any + logger?: BasicLogger +} + export class Executor { - constructor(options) { + options: Options + adapter: BaseAdapter + job: any | null + logger: BasicLogger + + constructor(options: Options) { this.options = options - this.adapter = options?.adapter - this.job = options?.job - this.logger = options?.logger || console + this.adapter = options.adapter + this.job = options.job + this.logger = options.logger || console if (!this.adapter) { throw new AdapterRequiredError() @@ -32,7 +46,7 @@ export class Executor { const jobModule = await loadJob(details.handler) await new jobModule[details.handler]().perform(...details.args) return this.adapter.success(this.job) - } catch (e) { + } catch (e: any) { let error = e if (e.message.match(/is not a constructor/)) { error = new JobExportNotFoundError(details.handler) From b8247b816a8dcf1f99efa57da7f87163d03014b1 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 3 Jul 2024 16:27:25 -0700 Subject: [PATCH 046/258] Comments on types --- packages/jobs/src/types.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/jobs/src/types.ts b/packages/jobs/src/types.ts index 1b1061861e50..eead5c8764f6 100644 --- a/packages/jobs/src/types.ts +++ b/packages/jobs/src/types.ts @@ -1,3 +1,7 @@ +// Defines the basic shape of a logger that RedwoodJob will invoke to print +// debug messages. Both Redwood's Logger and the standard console object +// conform to this shape. RedwoodJob will fallback to use `console` if no +// logger is passed in to RedwoodJob or any adapter. export interface BasicLogger { debug: (message?: any, ...optionalParams: any[]) => void info: (message?: any, ...optionalParams: any[]) => void @@ -14,7 +18,10 @@ export interface SchedulePayload { priority: number } -// Arguments returned from an adapter when a job is found +// Arguments returned from an adapter when a job is found. This is the absolute +// minimum interface that's needed for the Executor to invoke the job, but any +// adapter will likely return more info, like the number of previous tries, so +// that it can reschedule the job to run in the future. export interface BaseJob { handler: string args: any From a121a89eff7cb0587d1bad5cba856d39e94704b3 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 3 Jul 2024 20:56:06 -0700 Subject: [PATCH 047/258] Switches PrismaAdapter to TS --- packages/jobs/src/adapters/BaseAdapter.ts | 36 ++++++- .../{PrismaAdapter.js => PrismaAdapter.ts} | 93 ++++++++++++------- packages/jobs/src/types.ts | 24 +---- 3 files changed, 93 insertions(+), 60 deletions(-) rename packages/jobs/src/adapters/{PrismaAdapter.js => PrismaAdapter.ts} (79%) diff --git a/packages/jobs/src/adapters/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter.ts index b8cd5b167117..c749c80d8606 100644 --- a/packages/jobs/src/adapters/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter.ts @@ -7,24 +7,52 @@ import console from 'node:console' -import type { BasicLogger, BaseJob, SchedulePayload } from '../types' +import type { BasicLogger } from '../types' + +// Arguments sent to an adapter to schedule a job +export interface SchedulePayload { + handler: string + args: any + runAt: Date + queue: string + priority: number +} + +// Arguments returned from an adapter when a job is found. This is the absolute +// minimum interface that's needed for the Executor to invoke the job, but any +// adapter will likely return more info, like the number of previous tries, so +// that it can reschedule the job to run in the future. +export interface BaseJob { + handler: string + args: any +} + +export interface FindArgs { + processName: string + maxRuntime: number + queue: string +} + +export interface BaseAdapterOptions { + logger?: BasicLogger +} export abstract class BaseAdapter { options: any logger: BasicLogger - constructor(options: { logger?: BasicLogger }) { + constructor(options: BaseAdapterOptions) { this.options = options this.logger = options?.logger || console } abstract schedule(payload: SchedulePayload): void - abstract find(): BaseJob | null + abstract find(args: FindArgs): BaseJob | null | Promise abstract clear(): void abstract success(job: BaseJob): void - abstract failure(job: BaseJob, error: typeof Error): void + abstract failure(job: BaseJob, error: Error): void } diff --git a/packages/jobs/src/adapters/PrismaAdapter.js b/packages/jobs/src/adapters/PrismaAdapter.ts similarity index 79% rename from packages/jobs/src/adapters/PrismaAdapter.js rename to packages/jobs/src/adapters/PrismaAdapter.ts index 5ea801f75f5c..c0f35a0c85ab 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.js +++ b/packages/jobs/src/adapters/PrismaAdapter.ts @@ -25,17 +25,55 @@ // const adapter = new PrismaAdapter({ accessor: db.backgroundJob }) // RedwoodJob.config({ adapter }) +import type { PrismaClient } from '@prisma/client' import { camelCase } from 'change-case' -import { ModelNameError } from '../core/errors' - +import type { + BaseJob, + BaseAdapterOptions, + FindArgs, + SchedulePayload, +} from './BaseAdapter' import { BaseAdapter } from './BaseAdapter' export const DEFAULT_MODEL_NAME = 'BackgroundJob' export const DEFAULT_MAX_ATTEMPTS = 24 +interface PrismaJob extends BaseJob { + id: number + attempts: number + runAt: Date + lockedAt: Date + lockedBy: string + lastError: string | null + failedAt: Date | null + createdAt: Date + updatedAt: Date +} + +interface PrismaAdapterOptions extends BaseAdapterOptions { + db: PrismaClient + model?: string + accessor?: keyof PrismaClient + maxAttempts?: number +} + +interface FailureData { + lockedAt: null + lockedBy: null + lastError: string + failedAt?: Date + runAt: Date | null +} + export class PrismaAdapter extends BaseAdapter { - constructor(options) { + db: PrismaClient + model: string + accessor: PrismaClient[keyof PrismaClient] + provider: string + maxAttempts: number + + constructor(options: PrismaAdapterOptions) { super(options) // instance of PrismaClient @@ -47,25 +85,6 @@ export class PrismaAdapter extends BaseAdapter { // the function to call on `db` to make queries: `db.backgroundJob` this.accessor = this.db[camelCase(this.model)] - // the raw table name in the database - // if @@map() is used in the schema then the name will be present in - // db._runtimeDataModel - // otherwise it is the same as the model name - try { - this.tableName = - options.tableName || - this.db._runtimeDataModel.models[this.model].dbName || - this.model - } catch (e) { - // model name must not be right because `this.model` wasn't found in - // `this.db._runtimeDataModel.models` - if (e.name === 'TypeError' && e.message.match("reading 'dbName'")) { - throw new ModelNameError(this.model) - } else { - throw e - } - } - // the database provider type: 'sqlite' | 'postgresql' | 'mysql' this.provider = options.db._activeProvider @@ -77,8 +96,12 @@ export class PrismaAdapter extends BaseAdapter { // raw SQL to do it in each case—Prisma doesn't provide enough flexibility // in their generated code to do this in a DB-agnostic way. // TODO there may be more optimzed versions of the locking queries in Postgres and MySQL, this.options.db._activeProvider returns the provider name - async find({ processName, maxRuntime, queue }) { - const maxRuntimeExpire = new Date(new Date() - maxRuntime) + async find({ + processName, + maxRuntime, + queue, + }: FindArgs): Promise { + const maxRuntimeExpire = new Date(new Date().getTime() + maxRuntime) // This query is gnarly but not so bad once you know what it's doing. For a // job to match it must: @@ -121,7 +144,7 @@ export class PrismaAdapter extends BaseAdapter { }) // Find the next job that should run now - let job = await this.accessor.findFirst({ + const job = await this.accessor.findFirst({ select: { id: true, attempts: true }, where: whereWithQueue, orderBy: [{ priority: 'asc' }, { runAt: 'asc' }], @@ -156,29 +179,29 @@ export class PrismaAdapter extends BaseAdapter { return null } - success(job) { + success(job: PrismaJob) { this.logger.debug(`Job ${job.id} success`) - return this.accessor.delete({ where: { id: job.id } }) + this.accessor.delete({ where: { id: job.id } }) } - failure(job, error) { + failure(job: PrismaJob, error: Error) { this.logger.debug(`Job ${job.id} failure`) - const data = { + const data: FailureData = { lockedAt: null, lockedBy: null, lastError: `${error.message}\n\n${error.stack}`, + runAt: null, } if (job.attempts >= this.maxAttempts) { data.failedAt = new Date() - data.runAt = null } else { data.runAt = new Date( new Date().getTime() + this.backoffMilliseconds(job.attempts), ) } - return this.accessor.update({ + this.accessor.update({ where: { id: job.id }, data, }) @@ -186,8 +209,8 @@ export class PrismaAdapter extends BaseAdapter { // Schedules a job by creating a new record in a `BackgroundJob` table // (or whatever the accessor is configured to point to). - schedule({ handler, args, runAt, queue, priority }) { - return this.accessor.create({ + schedule({ handler, args, runAt, queue, priority }: SchedulePayload) { + this.accessor.create({ data: { handler: JSON.stringify({ handler, args }), runAt, @@ -198,10 +221,10 @@ export class PrismaAdapter extends BaseAdapter { } clear() { - return this.accessor.deleteMany() + this.accessor.deleteMany() } - backoffMilliseconds(attempts) { + backoffMilliseconds(attempts: number) { return 1000 * attempts ** 4 } } diff --git a/packages/jobs/src/types.ts b/packages/jobs/src/types.ts index eead5c8764f6..a2edcdb1a55f 100644 --- a/packages/jobs/src/types.ts +++ b/packages/jobs/src/types.ts @@ -1,28 +1,10 @@ // Defines the basic shape of a logger that RedwoodJob will invoke to print -// debug messages. Both Redwood's Logger and the standard console object -// conform to this shape. RedwoodJob will fallback to use `console` if no -// logger is passed in to RedwoodJob or any adapter. +// debug messages. RedwoodJob will fallback to use `console` if no +// logger is passed in to RedwoodJob or any adapter. Luckily both Redwood's +// Logger and the standard console logger conform to this shape. export interface BasicLogger { debug: (message?: any, ...optionalParams: any[]) => void info: (message?: any, ...optionalParams: any[]) => void warn: (message?: any, ...optionalParams: any[]) => void error: (message?: any, ...optionalParams: any[]) => void } - -// Arguments sent to an adapter to schedule a job -export interface SchedulePayload { - handler: string - args: any - runAt: Date - queue: string - priority: number -} - -// Arguments returned from an adapter when a job is found. This is the absolute -// minimum interface that's needed for the Executor to invoke the job, but any -// adapter will likely return more info, like the number of previous tries, so -// that it can reschedule the job to run in the future. -export interface BaseJob { - handler: string - args: any -} From 83f47706f952a9963374028d3a457cbea389b937 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Sun, 7 Jul 2024 13:38:27 -0700 Subject: [PATCH 048/258] Update tests to it() instead of test(), reword a couple tests --- packages/jobs/src/adapters/PrismaAdapter.ts | 7 ++ .../adapters/__tests__/BaseAdapter.test.js | 38 +------ .../adapters/__tests__/PrismaAdapter.test.js | 70 +++++-------- .../src/bins/__tests__/rw-jobs-worker.test.js | 6 +- .../jobs/src/bins/__tests__/rw-jobs.test.js | 4 +- packages/jobs/src/core/Executor.ts | 1 + .../jobs/src/core/__tests__/Executor.test.js | 18 ++-- .../src/core/__tests__/RedwoodJob.test.js | 98 +++++++++---------- .../jobs/src/core/__tests__/Worker.test.js | 44 ++++----- 9 files changed, 118 insertions(+), 168 deletions(-) diff --git a/packages/jobs/src/adapters/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter.ts index c0f35a0c85ab..67a340bc1f4d 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter.ts @@ -28,6 +28,8 @@ import type { PrismaClient } from '@prisma/client' import { camelCase } from 'change-case' +import { ModelNameError } from '../core/errors' + import type { BaseJob, BaseAdapterOptions, @@ -89,6 +91,11 @@ export class PrismaAdapter extends BaseAdapter { this.provider = options.db._activeProvider this.maxAttempts = options?.maxAttempts || DEFAULT_MAX_ATTEMPTS + + // validate that everything we need is available + if (!this.accessor) { + throw new ModelNameError(this.model) + } } // Finds the next job to run, locking it so that no other process can pick it diff --git a/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js b/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js index 2fc37d8f9903..dd116d76fbb7 100644 --- a/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js +++ b/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js @@ -1,51 +1,19 @@ -import { describe, expect, vi, test } from 'vitest' +import { describe, expect, vi, it } from 'vitest' import * as errors from '../../core/errors' import { BaseAdapter } from '../BaseAdapter' describe('constructor', () => { - test('initializing the adapter saves options', () => { + it('saves options', () => { const adapter = new BaseAdapter({ foo: 'bar' }) expect(adapter.options.foo).toEqual('bar') }) - test('creates a separate instance var for any logger', () => { + it('creates a separate instance var for any logger', () => { const mockLogger = vi.fn() const adapter = new BaseAdapter({ foo: 'bar', logger: mockLogger }) expect(adapter.logger).toEqual(mockLogger) }) }) - -describe('schedule()', () => { - test('throws an error if not implemented', () => { - const adapter = new BaseAdapter({}) - - expect(() => adapter.schedule()).toThrow(errors.NotImplementedError) - }) -}) - -describe('find()', () => { - test('throws an error if not implemented', () => { - const adapter = new BaseAdapter({}) - - expect(() => adapter.find()).toThrow(errors.NotImplementedError) - }) -}) - -describe('success()', () => { - test('throws an error if not implemented', () => { - const adapter = new BaseAdapter({}) - - expect(() => adapter.success()).toThrow(errors.NotImplementedError) - }) -}) - -describe('failure()', () => { - test('throws an error if not implemented', () => { - const adapter = new BaseAdapter({}) - - expect(() => adapter.failure()).toThrow(errors.NotImplementedError) - }) -}) diff --git a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js index 8f5530008ec2..162c5eeba290 100644 --- a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js +++ b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js @@ -1,4 +1,4 @@ -import { describe, expect, vi, test, beforeEach, afterEach } from 'vitest' +import { describe, expect, vi, it, beforeEach, afterEach } from 'vitest' import * as errors from '../../core/errors' import { @@ -37,13 +37,13 @@ afterEach(() => { }) describe('constructor', () => { - test('defaults this.model name', () => { + it('defaults this.model name', () => { const adapter = new PrismaAdapter({ db: mockDb }) expect(adapter.model).toEqual(DEFAULT_MODEL_NAME) }) - test('can manually set this.model', () => { + it('can manually set this.model', () => { mockDb._runtimeDataModel.models = { Job: { dbName: null, @@ -59,55 +59,31 @@ describe('constructor', () => { expect(adapter.model).toEqual('Job') }) - test('throws an error with a model name that does not exist', () => { + it('throws an error with a model name that does not exist', () => { expect(() => new PrismaAdapter({ db: mockDb, model: 'FooBar' })).toThrow( errors.ModelNameError, ) }) - test('sets this.accessor to the correct Prisma accessor', () => { + it('sets this.accessor to the correct Prisma accessor', () => { const adapter = new PrismaAdapter({ db: mockDb }) expect(adapter.accessor).toEqual(mockDb.backgroundJob) }) - test('manually set this.tableName ', () => { - const adapter = new PrismaAdapter({ - db: mockDb, - tableName: 'background_jobz', - }) - - expect(adapter.tableName).toEqual('background_jobz') - }) - - test('set this.tableName from custom @@map() name in schema', () => { - mockDb._runtimeDataModel.models.BackgroundJob.dbName = 'bg_jobs' - const adapter = new PrismaAdapter({ - db: mockDb, - }) - - expect(adapter.tableName).toEqual('bg_jobs') - }) - - test('default this.tableName to camelCase version of model name', () => { - const adapter = new PrismaAdapter({ db: mockDb }) - - expect(adapter.tableName).toEqual('BackgroundJob') - }) - - test('sets this.provider based on the active provider', () => { + it('sets this.provider based on the active provider', () => { const adapter = new PrismaAdapter({ db: mockDb }) expect(adapter.provider).toEqual('sqlite') }) - test('defaults this.maxAttempts', () => { + it('defaults this.maxAttempts', () => { const adapter = new PrismaAdapter({ db: mockDb }) expect(adapter.maxAttempts).toEqual(DEFAULT_MAX_ATTEMPTS) }) - test('can manually set this.maxAttempts', () => { + it('allows manually setting this.maxAttempts', () => { const adapter = new PrismaAdapter({ db: mockDb, maxAttempts: 10 }) expect(adapter.maxAttempts).toEqual(10) @@ -115,7 +91,7 @@ describe('constructor', () => { }) describe('schedule()', () => { - test('creates a job in the DB with required data', async () => { + it('creates a job in the DB with required data', async () => { const createSpy = vi .spyOn(mockDb.backgroundJob, 'create') .mockReturnValue({ id: 1 }) @@ -143,7 +119,7 @@ describe('schedule()', () => { }) describe('find()', () => { - test('returns null if no job found', async () => { + it('returns null if no job found', async () => { vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(null) const adapter = new PrismaAdapter({ db: mockDb }) const job = await adapter.find({ @@ -155,7 +131,7 @@ describe('find()', () => { expect(job).toBeNull() }) - test('returns a job if found', async () => { + it('returns a job if found', async () => { const mockJob = { id: 1 } vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) vi.spyOn(mockDb.backgroundJob, 'updateMany').mockReturnValue({ count: 1 }) @@ -169,7 +145,7 @@ describe('find()', () => { expect(job).toEqual(mockJob) }) - test('increments the `attempts` count on the found job', async () => { + it('increments the `attempts` count on the found job', async () => { const mockJob = { id: 1, attempts: 0 } vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) const updateSpy = vi @@ -189,7 +165,7 @@ describe('find()', () => { ) }) - test('locks the job for the current process', async () => { + it('locks the job for the current process', async () => { const mockJob = { id: 1, attempts: 0 } vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) const updateSpy = vi @@ -209,7 +185,7 @@ describe('find()', () => { ) }) - test('locks the job with a current timestamp', async () => { + it('locks the job with a current timestamp', async () => { const mockJob = { id: 1, attempts: 0 } vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) const updateSpy = vi @@ -231,7 +207,7 @@ describe('find()', () => { }) describe('success()', () => { - test('deletes the job from the DB', async () => { + it('deletes the job from the DB', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'delete') const adapter = new PrismaAdapter({ db: mockDb }) await adapter.success({ id: 1 }) @@ -241,7 +217,7 @@ describe('success()', () => { }) describe('failure()', () => { - test('updates the job by id', async () => { + it('updates the job by id', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') const adapter = new PrismaAdapter({ db: mockDb }) await adapter.failure({ id: 1 }, new Error('test error')) @@ -251,7 +227,7 @@ describe('failure()', () => { ) }) - test('clears the lock fields', async () => { + it('clears the lock fields', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') const adapter = new PrismaAdapter({ db: mockDb }) await adapter.failure({ id: 1 }, new Error('test error')) @@ -263,7 +239,7 @@ describe('failure()', () => { ) }) - test('reschedules the job at a designated backoff time', async () => { + it('reschedules the job at a designated backoff time', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') const adapter = new PrismaAdapter({ db: mockDb }) await adapter.failure({ id: 1, attempts: 10 }, new Error('test error')) @@ -277,7 +253,7 @@ describe('failure()', () => { ) }) - test('records the error', async () => { + it('records the error', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') const adapter = new PrismaAdapter({ db: mockDb }) await adapter.failure({ id: 1, attempts: 10 }, new Error('test error')) @@ -291,7 +267,7 @@ describe('failure()', () => { ) }) - test('marks the job as failed if max attempts reached', async () => { + it('marks the job as failed if max attempts reached', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') const adapter = new PrismaAdapter({ db: mockDb }) await adapter.failure({ id: 1, attempts: 24 }, new Error('test error')) @@ -305,7 +281,7 @@ describe('failure()', () => { ) }) - test('nullifies runtAt if max attempts reached', async () => { + it('nullifies runtAt if max attempts reached', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') const adapter = new PrismaAdapter({ db: mockDb }) await adapter.failure({ id: 1, attempts: 24 }, new Error('test error')) @@ -321,7 +297,7 @@ describe('failure()', () => { }) describe('clear()', () => { - test('deletes all jobs from the DB', async () => { + it('deletes all jobs from the DB', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'deleteMany') const adapter = new PrismaAdapter({ db: mockDb }) @@ -332,7 +308,7 @@ describe('clear()', () => { }) describe('backoffMilliseconds()', () => { - test('returns the number of milliseconds to wait for the next run', () => { + it('returns the number of milliseconds to wait for the next run', () => { expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(0)).toEqual(0) expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(1)).toEqual( 1000, diff --git a/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js b/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js index 36eb60048b53..ac43208c1486 100644 --- a/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js +++ b/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js @@ -1,6 +1,6 @@ import path from 'node:path' -import { describe, expect, vi, test } from 'vitest' +import { describe, expect, vi, it } from 'vitest' // import * as worker from '../worker' @@ -8,9 +8,7 @@ import { describe, expect, vi, test } from 'vitest' vi.mock('@redwoodjs/babel-config') describe('worker', () => { - test('placeholder', () => { - console.info(process.env.RWJS_CWD) - + it('placeholder', () => { expect(true).toBeTruthy() }) }) diff --git a/packages/jobs/src/bins/__tests__/rw-jobs.test.js b/packages/jobs/src/bins/__tests__/rw-jobs.test.js index 1b3d0f15b411..23cdefc064dc 100644 --- a/packages/jobs/src/bins/__tests__/rw-jobs.test.js +++ b/packages/jobs/src/bins/__tests__/rw-jobs.test.js @@ -1,9 +1,9 @@ -import { describe, expect, vi, test } from 'vitest' +import { describe, expect, vi, it } from 'vitest' // import * as runner from '../runner' describe('runner', () => { - test.skip('placeholder', () => { + it.skip('placeholder', () => { expect(true).toBeTruthy() }) }) diff --git a/packages/jobs/src/core/Executor.ts b/packages/jobs/src/core/Executor.ts index 673232c85c2e..d88ee2d0aea3 100644 --- a/packages/jobs/src/core/Executor.ts +++ b/packages/jobs/src/core/Executor.ts @@ -30,6 +30,7 @@ export class Executor { this.job = options.job this.logger = options.logger || console + // validate that everything we need is available if (!this.adapter) { throw new AdapterRequiredError() } diff --git a/packages/jobs/src/core/__tests__/Executor.test.js b/packages/jobs/src/core/__tests__/Executor.test.js index 9d74a055030b..32066a318dde 100644 --- a/packages/jobs/src/core/__tests__/Executor.test.js +++ b/packages/jobs/src/core/__tests__/Executor.test.js @@ -1,4 +1,4 @@ -import { describe, expect, vi, test } from 'vitest' +import { describe, expect, vi, it } from 'vitest' import * as errors from '../../core/errors' import { Executor } from '../Executor' @@ -7,34 +7,34 @@ import { Executor } from '../Executor' vi.mock('@redwoodjs/babel-config') describe('constructor', () => { - test('saves options', () => { + it('saves options', () => { const options = { adapter: 'adapter', job: 'job' } const exector = new Executor(options) expect(exector.options).toEqual(options) }) - test('extracts adapter from options to variable', () => { + it('extracts adapter from options to variable', () => { const options = { adapter: 'adapter', job: 'job' } const exector = new Executor(options) expect(exector.adapter).toEqual('adapter') }) - test('extracts job from options to variable', () => { + it('extracts job from options to variable', () => { const options = { adapter: 'adapter', job: 'job' } const exector = new Executor(options) expect(exector.job).toEqual('job') }) - test('throws AdapterRequiredError if adapter is not provided', () => { + it('throws AdapterRequiredError if adapter is not provided', () => { const options = { job: 'job' } expect(() => new Executor(options)).toThrow(errors.AdapterRequiredError) }) - test('throws JobRequiredError if job is not provided', () => { + it('throws JobRequiredError if job is not provided', () => { const options = { adapter: 'adapter' } expect(() => new Executor(options)).toThrow(errors.JobRequiredError) @@ -43,7 +43,7 @@ describe('constructor', () => { describe('perform', () => { // TODO once these dynamic imports are converted into loadJob in shared, just mock out the result of loadJob - test.skip('invokes the `perform` method on the job class', async () => { + it.skip('invokes the `perform` method on the job class', async () => { const options = { adapter: 'adapter', job: { handler: JSON.stringify({ handler: 'Foo', args: ['bar'] }) }, @@ -61,7 +61,7 @@ describe('perform', () => { expect(mockJob).toHaveBeenCalledWith('bar') }) - test.skip('invokes the `success` method on the adapter when job successful', async () => {}) + it.skip('invokes the `success` method on the adapter when job successful', async () => {}) - test.skip('invokes the `failure` method on the adapter when job fails', async () => {}) + it.skip('invokes the `failure` method on the adapter when job fails', async () => {}) }) diff --git a/packages/jobs/src/core/__tests__/RedwoodJob.test.js b/packages/jobs/src/core/__tests__/RedwoodJob.test.js index 0c2604a69589..ab6521748686 100644 --- a/packages/jobs/src/core/__tests__/RedwoodJob.test.js +++ b/packages/jobs/src/core/__tests__/RedwoodJob.test.js @@ -1,4 +1,4 @@ -import { describe, expect, vi, test, beforeEach } from 'vitest' +import { describe, expect, vi, it, beforeEach } from 'vitest' import * as errors from '../../core/errors' import { RedwoodJob } from '../RedwoodJob' @@ -6,7 +6,7 @@ import { RedwoodJob } from '../RedwoodJob' vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) describe('static config', () => { - test('can set the adapter', () => { + it('can set the adapter', () => { const adapter = { schedule: vi.fn() } RedwoodJob.config({ adapter }) @@ -14,7 +14,7 @@ describe('static config', () => { expect(RedwoodJob.adapter).toEqual(adapter) }) - test('can set the logger', () => { + it('can set the logger', () => { const logger = { info: vi.fn() } RedwoodJob.config({ logger }) @@ -22,7 +22,7 @@ describe('static config', () => { expect(RedwoodJob.logger).toEqual(logger) }) - test('can explictly set the adapter to falsy values for testing', () => { + it('can explictly set the adapter to falsy values for testing', () => { RedwoodJob.config({ adapter: null }) expect(RedwoodJob.adapter).toBeNull() @@ -35,12 +35,12 @@ describe('static config', () => { }) describe('constructor()', () => { - test('returns an instance of the job', () => { + it('returns an instance of the job', () => { const job = new RedwoodJob() expect(job).toBeInstanceOf(RedwoodJob) }) - test('defaults some options', () => { + it('defaults some options', () => { const job = new RedwoodJob() expect(job.options).toEqual({ queue: RedwoodJob.queue, @@ -48,44 +48,44 @@ describe('constructor()', () => { }) }) - test('can set options for the job', () => { + it('can set options for the job', () => { const job = new RedwoodJob({ foo: 'bar' }) expect(job.options.foo).toEqual('bar') }) }) describe('static set()', () => { - test('returns a job instance', () => { + it('returns a job instance', () => { const job = RedwoodJob.set({ wait: 300 }) expect(job).toBeInstanceOf(RedwoodJob) }) - test('sets options for the job', () => { + it('sets options for the job', () => { const job = RedwoodJob.set({ foo: 'bar' }) expect(job.options.foo).toEqual('bar') }) - test('sets the default queue', () => { + it('sets the default queue', () => { const job = RedwoodJob.set({ foo: 'bar' }) expect(job.options.queue).toEqual(RedwoodJob.queue) }) - test('sets the default priority', () => { + it('sets the default priority', () => { const job = RedwoodJob.set({ foo: 'bar' }) expect(job.options.priority).toEqual(RedwoodJob.priority) }) - test('can override the queue name set in the class', () => { + it('can override the queue name set in the class', () => { const job = RedwoodJob.set({ foo: 'bar', queue: 'priority' }) expect(job.options.queue).toEqual('priority') }) - test('can override the priority set in the class', () => { + it('can override the priority set in the class', () => { const job = RedwoodJob.set({ foo: 'bar', priority: 10 }) expect(job.options.priority).toEqual(10) @@ -93,37 +93,37 @@ describe('static set()', () => { }) describe('instance set()', () => { - test('returns a job instance', () => { + it('returns a job instance', () => { const job = new RedwoodJob().set({ wait: 300 }) expect(job).toBeInstanceOf(RedwoodJob) }) - test('sets options for the job', () => { + it('sets options for the job', () => { const job = new RedwoodJob().set({ foo: 'bar' }) expect(job.options.foo).toEqual('bar') }) - test('sets the default queue', () => { + it('sets the default queue', () => { const job = new RedwoodJob().set({ foo: 'bar' }) expect(job.options.queue).toEqual(RedwoodJob.queue) }) - test('sets the default priority', () => { + it('sets the default priority', () => { const job = new RedwoodJob().set({ foo: 'bar' }) expect(job.options.priority).toEqual(RedwoodJob.priority) }) - test('can override the queue name set in the class', () => { + it('can override the queue name set in the class', () => { const job = new RedwoodJob().set({ foo: 'bar', queue: 'priority' }) expect(job.options.queue).toEqual('priority') }) - test('can override the priority set in the class', () => { + it('can override the priority set in the class', () => { const job = new RedwoodJob().set({ foo: 'bar', priority: 10 }) expect(job.options.priority).toEqual(10) @@ -131,19 +131,19 @@ describe('instance set()', () => { }) describe('get runAt()', () => { - test('returns the current time if no options are set', () => { + it('returns the current time if no options are set', () => { const job = new RedwoodJob() expect(job.runAt).toEqual(new Date()) }) - test('returns a datetime `wait` seconds in the future if option set', async () => { + it('returns a datetime `wait` seconds in the future if option set', async () => { const job = RedwoodJob.set({ wait: 300 }) expect(job.runAt).toEqual(new Date(Date.UTC(2024, 0, 1, 0, 5, 0))) }) - test('returns a datetime set to `waitUntil` if option set', async () => { + it('returns a datetime set to `waitUntil` if option set', async () => { const futureDate = new Date(2030, 1, 2, 12, 34, 56) const job = RedwoodJob.set({ waitUntil: futureDate, @@ -152,7 +152,7 @@ describe('get runAt()', () => { expect(job.runAt).toEqual(futureDate) }) - test('returns any datetime set directly on the instance', () => { + it('returns any datetime set directly on the instance', () => { const futureDate = new Date(2030, 1, 2, 12, 34, 56) const job = new RedwoodJob() job.runAt = futureDate @@ -160,7 +160,7 @@ describe('get runAt()', () => { expect(job.runAt).toEqual(futureDate) }) - test('sets the computed time in the `options` property', () => { + it('sets the computed time in the `options` property', () => { const job = new RedwoodJob() const runAt = job.runAt @@ -169,7 +169,7 @@ describe('get runAt()', () => { }) describe('set runAt()', () => { - test('can set the runAt time directly on the instance', () => { + it('allows manually setting runAt time directly on the instance', () => { const futureDate = new Date(2030, 1, 2, 12, 34, 56) const job = new RedwoodJob() job.runAt = futureDate @@ -177,7 +177,7 @@ describe('set runAt()', () => { expect(job.runAt).toEqual(futureDate) }) - test('sets the `options.runAt` property', () => { + it('sets the `options.runAt` property', () => { const futureDate = new Date(2030, 1, 2, 12, 34, 56) const job = new RedwoodJob() job.runAt = futureDate @@ -187,20 +187,20 @@ describe('set runAt()', () => { }) describe('get queue()', () => { - test('defaults to queue set in class', () => { + it('defaults to queue set in class', () => { const job = new RedwoodJob() expect(job.queue).toEqual(RedwoodJob.queue) }) - test('can manually set the queue name on an instance', () => { + it('allows manually setting the queue name on an instance', () => { const job = new RedwoodJob() job.queue = 'priority' expect(job.queue).toEqual('priority') }) - test('queue set manually overrides queue set as an option', () => { + it('prefers the queue set manually over queue set as an option', () => { const job = RedwoodJob.set({ queue: 'priority' }) job.queue = 'important' @@ -209,7 +209,7 @@ describe('get queue()', () => { }) describe('set queue()', () => { - test('sets the queue name in `options.queue`', () => { + it('sets the queue name in `options.queue`', () => { const job = new RedwoodJob() job.queue = 'priority' @@ -218,20 +218,20 @@ describe('set queue()', () => { }) describe('get priority()', () => { - test('defaults to priority set in class', () => { + it('defaults to priority set in class', () => { const job = new RedwoodJob() expect(job.priority).toEqual(RedwoodJob.priority) }) - test('can manually set the priority name on an instance', () => { + it('allows manually setting the priority name on an instance', () => { const job = new RedwoodJob() job.priority = 10 expect(job.priority).toEqual(10) }) - test('priority set manually overrides priority set as an option', () => { + it('prefers priority set manually over priority set as an option', () => { const job = RedwoodJob.set({ priority: 20 }) job.priority = 10 @@ -240,7 +240,7 @@ describe('get priority()', () => { }) describe('set priority()', () => { - test('sets the priority in `options.priority`', () => { + it('sets the priority in `options.priority`', () => { const job = new RedwoodJob() job.priority = 10 @@ -253,7 +253,7 @@ describe('static performLater()', () => { vi.clearAllMocks() }) - test('invokes the instance performLater()', () => { + it('invokes the instance performLater()', () => { class TestJob extends RedwoodJob { async perform() { return 'done' @@ -274,7 +274,7 @@ describe('instance performLater()', () => { vi.clearAllMocks() }) - test('throws an error if no adapter is configured', async () => { + it('throws an error if no adapter is configured', async () => { RedwoodJob.config({ adapter: undefined }) const job = new RedwoodJob() @@ -284,7 +284,7 @@ describe('instance performLater()', () => { ) }) - test('logs that the job is being scheduled', async () => { + it('logs that the job is being scheduled', async () => { class TestJob extends RedwoodJob { async perform() { return 'done' @@ -309,7 +309,7 @@ describe('instance performLater()', () => { ) }) - test('calls the `schedule` function on the adapter', async () => { + it('calls the `schedule` function on the adapter', async () => { class TestJob extends RedwoodJob { async perform() { return 'done' @@ -330,7 +330,7 @@ describe('instance performLater()', () => { }) }) - test('returns whatever the adapter returns', async () => { + it('returns whatever the adapter returns', async () => { class TestJob extends RedwoodJob { async perform() { return 'done' @@ -347,7 +347,7 @@ describe('instance performLater()', () => { expect(result).toEqual(scheduleReturn) }) - test('catches any errors thrown during schedulding and throws custom error', async () => { + it('catches any errors thrown during schedulding and throws custom error', async () => { class TestJob extends RedwoodJob { async perform() { return 'done' @@ -377,7 +377,7 @@ describe('static performNow()', () => { vi.clearAllMocks() }) - test('invokes the instance performNow()', () => { + it('invokes the instance performNow()', () => { class TestJob extends RedwoodJob { async perform() { return 'done' @@ -398,7 +398,7 @@ describe('instance performNow()', () => { vi.clearAllMocks() }) - test('throws an error if perform() function is not implemented', async () => { + it('throws an error if perform() function is not implemented', async () => { class TestJob extends RedwoodJob {} const job = new TestJob() @@ -407,7 +407,7 @@ describe('instance performNow()', () => { ) }) - test('logs that the job is being run', async () => { + it('logs that the job is being run', async () => { class TestJob extends RedwoodJob { async perform() { return 'done' @@ -432,7 +432,7 @@ describe('instance performNow()', () => { ) }) - test('invokes the perform() function immediately', async () => { + it('invokes the perform() function immediately', async () => { class TestJob extends RedwoodJob { async perform() { return 'done' @@ -446,7 +446,7 @@ describe('instance performNow()', () => { expect(spy).toHaveBeenCalledWith('foo', 'bar') }) - test('returns whatever the perform() function returns', async () => { + it('returns whatever the perform() function returns', async () => { const performReturn = { status: 'done' } class TestJob extends RedwoodJob { async perform() { @@ -459,7 +459,7 @@ describe('instance performNow()', () => { expect(result).toEqual(performReturn) }) - test('catches any errors thrown during perform and throws custom error', async () => { + it('catches any errors thrown during perform and throws custom error', async () => { class TestJob extends RedwoodJob { perform() { throw new Error('Could not perform') @@ -483,7 +483,7 @@ describe('instance performNow()', () => { }) describe('perform()', () => { - test('throws an error if not implemented', () => { + it('throws an error if not implemented', () => { const job = new RedwoodJob() expect(() => job.perform()).toThrow(errors.PerformNotImplementedError) @@ -491,7 +491,7 @@ describe('perform()', () => { }) describe('subclasses', () => { - test('can set their own default queue', () => { + it('can set its own queue', () => { class MailerJob extends RedwoodJob { static queue = 'mailers' } @@ -507,7 +507,7 @@ describe('subclasses', () => { expect(redwoodJob.queue).toEqual('default') }) - test('can set their own default priority', () => { + it('can set its own priority', () => { class PriorityJob extends RedwoodJob { static priority = 10 } diff --git a/packages/jobs/src/core/__tests__/Worker.test.js b/packages/jobs/src/core/__tests__/Worker.test.js index de440a9fe84f..972102f72da5 100644 --- a/packages/jobs/src/core/__tests__/Worker.test.js +++ b/packages/jobs/src/core/__tests__/Worker.test.js @@ -2,7 +2,7 @@ import { describe, expect, vi, - test, + it, beforeAll, afterAll, afterEach, @@ -22,105 +22,105 @@ vi.mock('@redwoodjs/babel-config') vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) describe('constructor', () => { - test('saves options', () => { + it('saves options', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) expect(worker.options).toEqual(options) }) - test('extracts adapter from options to variable', () => { + it('extracts adapter from options to variable', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) expect(worker.adapter).toEqual('adapter') }) - test('extracts queue from options to variable', () => { + it('extracts queue from options to variable', () => { const options = { adapter: 'adapter', queue: 'queue' } const worker = new Worker(options) expect(worker.queue).toEqual('queue') }) - test('queue will be null if no queue specified', () => { + it('queue will be null if no queue specified', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) expect(worker.queue).toBeNull() }) - test('extracts processName from options to variable', () => { + it('extracts processName from options to variable', () => { const options = { adapter: 'adapter', processName: 'processName' } const worker = new Worker(options) expect(worker.processName).toEqual('processName') }) - test('defaults processName if not provided', () => { + it('defaults processName if not provided', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) expect(worker.processName).not.toBeUndefined() }) - test('extracts maxRuntime from options to variable', () => { + it('extracts maxRuntime from options to variable', () => { const options = { adapter: 'adapter', maxRuntime: 1000 } const worker = new Worker(options) expect(worker.maxRuntime).toEqual(1000) }) - test('sets default maxRuntime if not provided', () => { + it('sets default maxRuntime if not provided', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) expect(worker.maxRuntime).toEqual(DEFAULT_MAX_RUNTIME) }) - test('extracts waitTime from options to variable', () => { + it('extracts waitTime from options to variable', () => { const options = { adapter: 'adapter', waitTime: 1000 } const worker = new Worker(options) expect(worker.waitTime).toEqual(1000) }) - test('sets default waitTime if not provided', () => { + it('sets default waitTime if not provided', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) expect(worker.waitTime).toEqual(DEFAULT_WAIT_TIME) }) - test('can set waitTime to 0', () => { + it('can set waitTime to 0', () => { const options = { adapter: 'adapter', waitTime: 0 } const worker = new Worker(options) expect(worker.waitTime).toEqual(0) }) - test('sets lastCheckTime to the current time', () => { + it('sets lastCheckTime to the current time', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) expect(worker.lastCheckTime).toBeInstanceOf(Date) }) - test('extracts forever from options to variable', () => { + it('extracts forever from options to variable', () => { const options = { adapter: 'adapter', forever: false } const worker = new Worker(options) expect(worker.forever).toEqual(false) }) - test('sets forever to `true` by default', () => { + it('sets forever to `true` by default', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) expect(worker.forever).toEqual(true) }) - test('throws an error if adapter not set', () => { + it('throws an error if adapter not set', () => { expect(() => new Worker()).toThrow(errors.AdapterRequiredError) }) }) @@ -142,7 +142,7 @@ describe('run', () => { console.debug = originalConsoleDebug }) - test('tries to find a job', async () => { + it('tries to find a job', async () => { const adapter = { find: vi.fn(() => null) } const worker = new Worker({ adapter, waitTime: 0, forever: false }) @@ -155,7 +155,7 @@ describe('run', () => { }) }) - test('does nothing if no job found and forever=false', async () => { + it('does nothing if no job found and forever=false', async () => { const adapter = { find: vi.fn(() => null) } vi.spyOn(Executor, 'constructor') @@ -165,7 +165,7 @@ describe('run', () => { expect(Executor).not.toHaveBeenCalled() }) - test('does nothing if no job found and workoff=true', async () => { + it('does nothing if no job found and workoff=true', async () => { const adapter = { find: vi.fn(() => null) } vi.spyOn(Executor, 'constructor') @@ -175,7 +175,7 @@ describe('run', () => { expect(Executor).not.toHaveBeenCalled() }) - test('initializes an Executor instance if the job is found', async () => { + it('initializes an Executor instance if the job is found', async () => { const adapter = { find: vi.fn(() => ({ id: 1 })) } const worker = new Worker({ adapter, waitTime: 0, forever: false }) @@ -188,7 +188,7 @@ describe('run', () => { }) }) - test('calls `perform` on the Executor instance', async () => { + it('calls `perform` on the Executor instance', async () => { const adapter = { find: vi.fn(() => ({ id: 1 })) } const spy = vi.spyOn(Executor.prototype, 'perform') const worker = new Worker({ adapter, waitTime: 0, forever: false }) @@ -198,7 +198,7 @@ describe('run', () => { expect(spy).toHaveBeenCalled() }) - test('calls `perform` on the Executor instance', async () => { + it('calls `perform` on the Executor instance', async () => { const adapter = { find: vi.fn(() => ({ id: 1 })) } const spy = vi.spyOn(Executor.prototype, 'perform') const worker = new Worker({ adapter, waitTime: 0, forever: false }) From d46dda8fb3e2aaf5239143fbd45c6506bf30577f Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Sun, 7 Jul 2024 13:59:55 -0700 Subject: [PATCH 049/258] PrismaAdapter should return the records that were created/edited/deleted when using schedule/success/failure/clear --- packages/jobs/src/adapters/BaseAdapter.ts | 8 ++++---- packages/jobs/src/adapters/PrismaAdapter.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/jobs/src/adapters/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter.ts index c749c80d8606..101cecda9d60 100644 --- a/packages/jobs/src/adapters/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter.ts @@ -46,13 +46,13 @@ export abstract class BaseAdapter { this.logger = options?.logger || console } - abstract schedule(payload: SchedulePayload): void + abstract schedule(payload: SchedulePayload): any abstract find(args: FindArgs): BaseJob | null | Promise - abstract clear(): void + abstract clear(): any - abstract success(job: BaseJob): void + abstract success(job: BaseJob): any - abstract failure(job: BaseJob, error: Error): void + abstract failure(job: BaseJob, error: Error): any } diff --git a/packages/jobs/src/adapters/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter.ts index 67a340bc1f4d..faa0cc5a0897 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter.ts @@ -188,7 +188,7 @@ export class PrismaAdapter extends BaseAdapter { success(job: PrismaJob) { this.logger.debug(`Job ${job.id} success`) - this.accessor.delete({ where: { id: job.id } }) + return this.accessor.delete({ where: { id: job.id } }) } failure(job: PrismaJob, error: Error) { @@ -208,7 +208,7 @@ export class PrismaAdapter extends BaseAdapter { ) } - this.accessor.update({ + return this.accessor.update({ where: { id: job.id }, data, }) @@ -217,7 +217,7 @@ export class PrismaAdapter extends BaseAdapter { // Schedules a job by creating a new record in a `BackgroundJob` table // (or whatever the accessor is configured to point to). schedule({ handler, args, runAt, queue, priority }: SchedulePayload) { - this.accessor.create({ + return this.accessor.create({ data: { handler: JSON.stringify({ handler, args }), runAt, @@ -228,7 +228,7 @@ export class PrismaAdapter extends BaseAdapter { } clear() { - this.accessor.deleteMany() + return this.accessor.deleteMany() } backoffMilliseconds(attempts: number) { From a86676149818dcb9d09129e50a795bd469304518 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Sun, 7 Jul 2024 17:06:37 -0700 Subject: [PATCH 050/258] Adds changeset --- .changesets/10906.md | 135 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 .changesets/10906.md diff --git a/.changesets/10906.md b/.changesets/10906.md new file mode 100644 index 000000000000..a84098508181 --- /dev/null +++ b/.changesets/10906.md @@ -0,0 +1,135 @@ +- Adds background job scheduling and execution (#10906) by @cannikin + +This new package provides scheduling and processing of background jobs. We want everything needed to run a modern web application to be included in Redwood itself—you shouldn't need any third party integrations if you don't want. Background jobs have been sorely missed, but the time has come! (If you do want to use a third party service we have had an [integration with Inngest](https://community.redwoodjs.com/t/ship-background-jobs-crons-webhooks-and-reliable-workflows-in-record-time-with-inngest-and-redwoodjs/4866) since May of 2023!) + +## What's Included + +- A base `RedwoodJob` class from which your own custom jobs will extend. You only need to fill out the details of a single `perform()` action, accepting whatever arguments you want, and the underlying RedwoodJob code will take care of scheduling, delaying, running, and, in the case your job fails, recording the error and rescheduling in the future for a retry. +- Backend adapters for storing your jobs. Today we're shipping with a `PrismaAdapter` but we also provide a `BaseAdapter` from which you can extend and build your own. +- A persistent process to watch for new jobs and execute them. It can be run in dev mode, which stays attached to your console so you can monitor and execute jobs in development, or in daemon mode which detaches from the console and runs in the background forever (you'll use this mode in production). + +Decoupling the jobs from their backends means you can swap out backends as your app grows, or even use different backends for different jobs! + +The actual Worker and Executor classes that know how to find a job and work on it are self-contained, so you can write your own runner if you want. + +## Features + +- Named queues: you can schedule jobs in separate named queues and have a different number of workers monitoring each one—makes it much easier to scale your background processing +- Priority: give your jobs a priority from 1 (highest) to 100 (lowest). Workers will sort available jobs by priority, working the most important ones first. +- Configurable delay: run your job as soon as possible (default), wait a number of seconds before running, or run at a specific time in the future +- Run inline: instead of scheduling to run in the background, run immediately +- Auto-retries with backoff: if your job fails it will back off at the rate of `attempts ** 4` for a default of 24 tries, the time between the last two attempts is a little over three days. The number of max retries is configurable per job. +- Integrates with Redwood's [logger](https://docs.redwoodjs.com/docs/logger): use your existing one in `api/src/lib/logger` or create a new one just for job logging + +## How it Works + +Using the `PrismaAdapter` means your jobs are stored in your database. The `yarn rw setup jobs` script will add a `BackgroundJob` model in your `schema.prisma` file. Any job that is invoked with `.performLater()` will add a row to this table: + +``` +WelcomeEmailJob.performLater({ user.email }) +``` + +If using the `PrismaAdapter`, any arguments you want to give to your job must be serializable as JSON since the values will be stored in the database as text. + +The persistent job workers (started in dev with `yarn rw jobs work` or detached to run in the background with `yarn rw jobs start`) will periodically check the database for any jobs that are qualified to run: not already locked by another worker and with a `runAt` time before or equal to right now. They'll lock the record, instantiate your job class and call `perform()` on it, passing in the arguments you gave when scheduling it. + +- If the job succeeds it is removed from the database +- If the job fails the error is recorded, the job is rescheduled to try again, and the lock is removed + +Repeat until the queue is empty! + +## Usage + +### Setup + +To simplify the setup, run the included setup script: + +``` +yarn rw setup jobs +``` + +This creates `api/src/lib/jobs` with the basic config included to get up and running, as well as the model added to your `schema.prisma` file. + +You can generate a job with the shell ready to go: + +``` +yarn rw g job WelcomeEmail +``` + +This creates a file at `api/src/jobs/WelcomeEmailJob.js` along with the shell of your job. All you need to is fill out the `perform()` function: + +```javascript +// api/src/jobs/WelcomeEmailJob.js + +export class WelcomeEmailJob extends RedwoodJob { + perform(email) { + // send email... + } +} +``` + +### Scheduling + +A typical place you'd use this job would be in a service. In this case, let's add it to the `users` service after creating a user: + +```javascript +// api/src/services/users/users.js + +export const createUser = async ({ input }) { + const user = await db.user.create({ data: input }) + await WelcomeEmailJob.performLater(user.email) + return user +}) +``` + +With the above syntax your job will run as soon as possible, in the queue named "default" and with a priority of 50. You can also delay your job for, say, 5 minutes: + +```javascript +OnboardingJob.set({ wait: 300 }).performLater(user.email) +``` + +Or run it at a specific time in the future: + +```javascript +MilleniumReminderJob.set({ waitUntil: new Date(2999, 11, 31, 12, 0, 0) }).performLater(user.email) +``` + +There are lots of ways to customize the scheduling and worker processes. Check out the docs for the full list! + +### Execution + +To run your jobs, start up the runner: + +```bash +yarn rw jobs work +``` + +This process will stay attached the console and continually look for new jobs and execute them as they are found. To work on whatever outstanding jobs there are and then exit, use the `workoff` mode instead. + +To run the worker(s) in the background, use the `start` mode: + +```bash +yarn rw jobs start +``` + +To stop them: + +```bash +yarn rw jobs stop +``` + +You can start more than one worker by passing the `-n` flag: + +```bash +yarn rw jobs start -n 4 +``` + +If you want to specify that some workers only work on certain named queues: + +```bash +yarn rw jobs start -n default:2,email:1 +``` + +Make sure you pass the same flags to the `stop` process as the `start` so it knows which ones to stop. You can `restart` your workers as well. + +In production you'll want to hook the workers up to a process monitor as, just like with any other process, they could die unexpectedly. More on this in the docs. From ae38c44b22ce91b1d7fd4d9266591343b63d6f75 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 8 Jul 2024 14:41:08 -0700 Subject: [PATCH 051/258] Enable dual build? --- packages/jobs/build.mts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/jobs/build.mts b/packages/jobs/build.mts index a607d8f03817..ad9464af1074 100644 --- a/packages/jobs/build.mts +++ b/packages/jobs/build.mts @@ -3,6 +3,5 @@ import { build, defaultBuildOptions } from '@redwoodjs/framework-tools' await build({ buildOptions: { ...defaultBuildOptions, - format: 'cjs', }, }) From 825076e7c0a38b25930dd8b677e2bd18f5223789 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 8 Jul 2024 14:41:25 -0700 Subject: [PATCH 052/258] Always await PrismaAdapter responses --- packages/jobs/src/adapters/BaseAdapter.ts | 6 ++++- packages/jobs/src/adapters/PrismaAdapter.ts | 22 +++++++++++-------- .../adapters/__tests__/BaseAdapter.test.js | 1 - 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/jobs/src/adapters/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter.ts index 101cecda9d60..053337265345 100644 --- a/packages/jobs/src/adapters/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter.ts @@ -46,9 +46,13 @@ export abstract class BaseAdapter { this.logger = options?.logger || console } + // It's up to the subclass to decide what to return for these functions. + // The job engine itself doesn't care about the return value, but the user may + // want to do something with the result depending on the adapter type, so make + // it `any` to allow for the subclass to return whatever it wants. abstract schedule(payload: SchedulePayload): any - abstract find(args: FindArgs): BaseJob | null | Promise + abstract find(args: FindArgs): BaseJob | null abstract clear(): any diff --git a/packages/jobs/src/adapters/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter.ts index faa0cc5a0897..c57fc44b4c19 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter.ts @@ -177,7 +177,7 @@ export class PrismaAdapter extends BaseAdapter { // Assuming the update worked, return the full details of the job if (count) { - return this.accessor.findFirst({ where: { id: job.id } }) + return await this.accessor.findFirst({ where: { id: job.id } }) } } @@ -186,12 +186,16 @@ export class PrismaAdapter extends BaseAdapter { return null } - success(job: PrismaJob) { + // Prisma queries are lazily evaluated and only sent to the db when they are + // awaited, so do the await here to ensure they actually run. Otherwise the + // user must always await `performLater()` or the job won't actually be + // scheduled. + async success(job: PrismaJob) { this.logger.debug(`Job ${job.id} success`) - return this.accessor.delete({ where: { id: job.id } }) + return await this.accessor.delete({ where: { id: job.id } }) } - failure(job: PrismaJob, error: Error) { + async failure(job: PrismaJob, error: Error) { this.logger.debug(`Job ${job.id} failure`) const data: FailureData = { lockedAt: null, @@ -208,7 +212,7 @@ export class PrismaAdapter extends BaseAdapter { ) } - return this.accessor.update({ + return await this.accessor.update({ where: { id: job.id }, data, }) @@ -216,8 +220,8 @@ export class PrismaAdapter extends BaseAdapter { // Schedules a job by creating a new record in a `BackgroundJob` table // (or whatever the accessor is configured to point to). - schedule({ handler, args, runAt, queue, priority }: SchedulePayload) { - return this.accessor.create({ + async schedule({ handler, args, runAt, queue, priority }: SchedulePayload) { + return await this.accessor.create({ data: { handler: JSON.stringify({ handler, args }), runAt, @@ -227,8 +231,8 @@ export class PrismaAdapter extends BaseAdapter { }) } - clear() { - return this.accessor.deleteMany() + async clear() { + return await this.accessor.deleteMany() } backoffMilliseconds(attempts: number) { diff --git a/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js b/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js index dd116d76fbb7..4f92c194e718 100644 --- a/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js +++ b/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js @@ -1,6 +1,5 @@ import { describe, expect, vi, it } from 'vitest' -import * as errors from '../../core/errors' import { BaseAdapter } from '../BaseAdapter' describe('constructor', () => { From 8b683cfcaa8b7471b8ff3c6fe2aaf67e6c9887e6 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 8 Jul 2024 14:47:57 -0700 Subject: [PATCH 053/258] Fix return type --- packages/jobs/src/adapters/BaseAdapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jobs/src/adapters/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter.ts index 053337265345..647e527dd828 100644 --- a/packages/jobs/src/adapters/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter.ts @@ -52,7 +52,7 @@ export abstract class BaseAdapter { // it `any` to allow for the subclass to return whatever it wants. abstract schedule(payload: SchedulePayload): any - abstract find(args: FindArgs): BaseJob | null + abstract find(args: FindArgs): BaseJob | null | Promise abstract clear(): any From df5e7025f33c951e3862c8f1f087cf6f20d3d95b Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 8 Jul 2024 14:48:08 -0700 Subject: [PATCH 054/258] Convert errors to TS --- .../jobs/src/core/{errors.js => errors.ts} | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) rename packages/jobs/src/core/{errors.js => errors.ts} (88%) diff --git a/packages/jobs/src/core/errors.js b/packages/jobs/src/core/errors.ts similarity index 88% rename from packages/jobs/src/core/errors.js rename to packages/jobs/src/core/errors.ts index 17d152cb939b..5f1de84a5dc5 100644 --- a/packages/jobs/src/core/errors.js +++ b/packages/jobs/src/core/errors.ts @@ -1,6 +1,6 @@ // Parent class for any RedwoodJob-related error export class RedwoodJobError extends Error { - constructor(message) { + constructor(message: string) { super(message) this.name = this.constructor.name } @@ -22,14 +22,14 @@ export class PerformNotImplementedError extends RedwoodJobError { // Thrown when a custom adapter does not implement the `schedule` method export class NotImplementedError extends RedwoodJobError { - constructor(name) { + constructor(name: string) { super(`You must implement the \`${name}\` method in your adapter`) } } // Thrown when a given model name isn't actually available in the PrismaClient export class ModelNameError extends RedwoodJobError { - constructor(name) { + constructor(name: string) { super(`Model \`${name}\` not found in PrismaClient`) } } @@ -50,14 +50,14 @@ export class JobRequiredError extends RedwoodJobError { // Thrown when a job with the given handler is not found in the filesystem export class JobNotFoundError extends RedwoodJobError { - constructor(name) { + constructor(name: string) { super(`Job \`${name}\` not found in the filesystem`) } } // Throw when a job file exists, but the export does not match the filename export class JobExportNotFoundError extends RedwoodJobError { - constructor(name) { + constructor(name: string) { super(`Job file \`${name}\` does not export a class with the same name`) } } @@ -89,7 +89,10 @@ export class AdapterNotFoundError extends RedwoodJobError { // throw new RethrowJobError('Custom Error Message', e) // } export class RethrownJobError extends RedwoodJobError { - constructor(message, error) { + originalError: Error + stackBeforeRethrow: string | undefined + + constructor(message: string, error: Error) { super(message) if (!error) { @@ -98,13 +101,13 @@ export class RethrownJobError extends RedwoodJobError { ) } - this.original_error = error - this.stack_before_rethrow = this.stack + this.originalError = error + this.stackBeforeRethrow = this.stack const messageLines = (this.message.match(/\n/g) || []).length + 1 this.stack = this.stack - .split('\n') + ?.split('\n') .slice(0, messageLines + 1) .join('\n') + '\n' + @@ -114,14 +117,14 @@ export class RethrownJobError extends RedwoodJobError { // Thrown when there is an error scheduling a job, wraps the underlying error export class SchedulingError extends RethrownJobError { - constructor(message, error) { + constructor(message: string, error: Error) { super(message, error) } } // Thrown when there is an error performing a job, wraps the underlying error export class PerformError extends RethrownJobError { - constructor(message, error) { + constructor(message: string, error: Error) { super(message, error) } } From e6065da58e13cdeb99061aba8c74266b927d17e0 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 8 Jul 2024 14:55:56 -0700 Subject: [PATCH 055/258] Converts Worker to TS --- .../jobs/src/core/{Worker.js => Worker.ts} | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) rename packages/jobs/src/core/{Worker.js => Worker.ts} (79%) diff --git a/packages/jobs/src/core/Worker.js b/packages/jobs/src/core/Worker.ts similarity index 79% rename from packages/jobs/src/core/Worker.js rename to packages/jobs/src/core/Worker.ts index 3c067e27cfd0..0d3a297e6d49 100644 --- a/packages/jobs/src/core/Worker.js +++ b/packages/jobs/src/core/Worker.ts @@ -4,14 +4,41 @@ import console from 'node:console' import process from 'node:process' import { setTimeout } from 'node:timers' +import type { BaseAdapter } from '../adapters/BaseAdapter' +import type { BasicLogger } from '../types' + import { AdapterRequiredError } from './errors' import { Executor } from './Executor' +interface WorkerOptions { + adapter: BaseAdapter + logger?: BasicLogger + clear?: boolean + processName?: string + queue?: string + maxRuntime?: number + waitTime?: number + forever?: boolean + workoff?: boolean +} + export const DEFAULT_WAIT_TIME = 5000 // 5 seconds export const DEFAULT_MAX_RUNTIME = 60 * 60 * 4 * 1000 // 4 hours export class Worker { - constructor(options) { + options: WorkerOptions + adapter: BaseAdapter + logger: BasicLogger + clear: boolean + processName: string + queue: string | null + maxRuntime: number + waitTime: number + lastCheckTime: Date + forever: boolean + workoff: boolean + + constructor(options: WorkerOptions) { this.options = options this.adapter = options?.adapter this.logger = options?.logger || console @@ -31,8 +58,9 @@ export class Worker { ? DEFAULT_MAX_RUNTIME : options.maxRuntime - // the amount of time to wait between checking for jobs. the time it took - // to run a job is subtracted from this time, so this is a maximum wait time + // the amount of time to wait in milliseconds between checking for jobs. + // the time it took to run a job is subtracted from this time, so this is a + // maximum wait time this.waitTime = options?.waitTime === undefined ? DEFAULT_WAIT_TIME : options.waitTime @@ -99,7 +127,8 @@ export class Worker { // sleep if there were no jobs found, otherwise get back to work if (!job && this.forever) { - const millsSinceLastCheck = new Date() - this.lastCheckTime + const millsSinceLastCheck = + new Date().getTime() - this.lastCheckTime.getTime() if (millsSinceLastCheck < this.waitTime) { await this.#wait(this.waitTime - millsSinceLastCheck) } @@ -107,7 +136,7 @@ export class Worker { } while (this.forever) } - #wait(ms) { + #wait(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } } From b3a2181f92c6fb12234d75ae3c5878c769f62e1e Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 8 Jul 2024 14:56:02 -0700 Subject: [PATCH 056/258] Fix test --- packages/jobs/src/core/__tests__/RedwoodJob.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/jobs/src/core/__tests__/RedwoodJob.test.js b/packages/jobs/src/core/__tests__/RedwoodJob.test.js index ab6521748686..a3236c920d99 100644 --- a/packages/jobs/src/core/__tests__/RedwoodJob.test.js +++ b/packages/jobs/src/core/__tests__/RedwoodJob.test.js @@ -367,7 +367,7 @@ describe('instance performLater()', () => { expect(e.message).toEqual( '[RedwoodJob] Exception when scheduling TestJob', ) - expect(e.original_error.message).toEqual('Could not schedule') + expect(e.originalError.message).toEqual('Could not schedule') } }) }) @@ -477,7 +477,7 @@ describe('instance performNow()', () => { } catch (e) { expect(e).toBeInstanceOf(errors.PerformError) expect(e.message).toEqual('[TestJob] exception when running job') - expect(e.original_error.message).toEqual('Could not perform') + expect(e.originalError.message).toEqual('Could not perform') } }) }) From 620698b3244f4b36cc6e38930352aaa554dd7f3b Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 8 Jul 2024 14:56:14 -0700 Subject: [PATCH 057/258] Queue can be `null` meaning "work on any queue" --- packages/jobs/src/adapters/BaseAdapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jobs/src/adapters/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter.ts index 647e527dd828..fddc8d4db256 100644 --- a/packages/jobs/src/adapters/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter.ts @@ -30,7 +30,7 @@ export interface BaseJob { export interface FindArgs { processName: string maxRuntime: number - queue: string + queue: string | null } export interface BaseAdapterOptions { From 495ee800a9a356a65679b120fd5e0517f79e6b00 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 8 Jul 2024 15:32:36 -0700 Subject: [PATCH 058/258] Converts RedwoodJob to TS --- .../src/core/{RedwoodJob.js => RedwoodJob.ts} | 92 +++++++++++++------ 1 file changed, 62 insertions(+), 30 deletions(-) rename packages/jobs/src/core/{RedwoodJob.js => RedwoodJob.ts} (70%) diff --git a/packages/jobs/src/core/RedwoodJob.js b/packages/jobs/src/core/RedwoodJob.ts similarity index 70% rename from packages/jobs/src/core/RedwoodJob.js rename to packages/jobs/src/core/RedwoodJob.ts index 673fca2dcd26..055710e7395a 100644 --- a/packages/jobs/src/core/RedwoodJob.js +++ b/packages/jobs/src/core/RedwoodJob.ts @@ -1,5 +1,20 @@ // Base class for all jobs, providing a common interface for scheduling jobs. // At a minimum you must implement the `perform` method in your job subclass. +// +// Configuring RedwoodJob is very flexible. You can set the adapter and logger +// once and all subclasses will use it: +// +// RedwoodJob.config({ adapter, logger }) +// +// Or set them in the individual subclasses: +// +// class MyJob extends RedwoodJob { +// static adapter = new MyAdapter() +// static logger = new MyLogger() +// } + +import type { BaseAdapter } from '../adapters/BaseAdapter' +import type { BasicLogger } from '../types' import { AdapterNotConfiguredError, @@ -8,6 +23,20 @@ import { PerformError, } from './errors' +export interface JobConfigOptions { + adapter: BaseAdapter + logger?: BasicLogger +} + +export interface JobSetOptions { + wait?: number + waitUntil?: Date + queue?: string + priority?: number + logger?: BasicLogger + runAt?: Date +} + export const DEFAULT_QUEUE = 'default' export class RedwoodJob { @@ -19,48 +48,44 @@ export class RedwoodJob { static priority = 50 // The adapter to use for scheduling jobs. Set via the static `config` method - static adapter + static adapter: BaseAdapter // Set via the static `config` method - static logger + static logger: BasicLogger - // Configure all jobs to use a specific adapter - static config(options) { - if (options) { - if (Object.keys(options).includes('adapter')) { - this.adapter = options.adapter - } - if (Object.keys(options).includes('logger')) { - this.logger = options.logger - } + // Configure all jobs to use a specific adapter and logger + static config(options: JobConfigOptions) { + if (Object.keys(options).includes('adapter')) { + this.adapter = options.adapter } + this.logger = options?.logger || console } // Class method to schedule a job to run later // const scheduleDetails = RedwoodJob.performLater('foo', 'bar') - static performLater(...args) { + static performLater(...args: any[]) { return new this().performLater(...args) } // Class method to run the job immediately in the current process // const result = RedwoodJob.performNow('foo', 'bar') - static performNow(...args) { + static performNow(...args: any[]) { return new this().performNow(...args) } // Set options on the job before enqueueing it: // const job = RedwoodJob.set({ wait: 300 }) // job.performLater('foo', 'bar') - static set(options) { - return new this().set(options) + static set(options: JobSetOptions = {}) { + return new this(options) } // Private property to store options set on the job - #options = {} + #options: JobSetOptions = {} // A job can be instantiated manually, but this will also be invoked // automatically by .set() or .performLater() - constructor(options) { + constructor(options: JobSetOptions = {}) { this.set(options) } @@ -75,7 +100,7 @@ export class RedwoodJob { // Instance method to schedule a job to run later // const job = RedwoodJob // const scheduleDetails = job.performLater('foo', 'bar') - performLater(...args) { + performLater(...args: any[]) { this.logger.info( this.payload(args), `[RedwoodJob] Scheduling ${this.constructor.name}`, @@ -86,7 +111,7 @@ export class RedwoodJob { // Instance method to runs the job immediately in the current process // const result = RedwoodJob.performNow('foo', 'bar') - performNow(...args) { + performNow(...args: any[]) { this.logger.info( this.payload(args), `[RedwoodJob] Running ${this.constructor.name} now`, @@ -94,7 +119,7 @@ export class RedwoodJob { try { return this.perform(...args) - } catch (e) { + } catch (e: any) { if (e instanceof PerformNotImplementedError) { throw e } else { @@ -107,28 +132,30 @@ export class RedwoodJob { } // Must be implemented by the subclass - perform() { + perform(..._args: any[]) { throw new PerformNotImplementedError() } // Returns data sent to the adapter for scheduling - payload(args) { + payload(args: any[]) { return { handler: this.constructor.name, args, - runAt: this.runAt, + runAt: this.runAt as Date, queue: this.queue, priority: this.priority, } } get logger() { - return this.#options?.logger || this.constructor.logger + return ( + this.#options?.logger || (this.constructor as typeof RedwoodJob).logger + ) } // Determines the name of the queue get queue() { - return this.#options?.queue || this.constructor.queue + return this.#options?.queue || (this.constructor as typeof RedwoodJob).queue } // Set the name of the queue directly on an instance of a job @@ -138,7 +165,10 @@ export class RedwoodJob { // Determines the priority of the job get priority() { - return this.#options?.priority || this.constructor.priority + return ( + this.#options?.priority || + (this.constructor as typeof RedwoodJob).priority + ) } // Set the priority of the job directly on an instance of a job @@ -182,14 +212,16 @@ export class RedwoodJob { // Private, schedules a job with the appropriate adapter, returns whatever // the adapter returns in response to a successful schedule. - #schedule(args) { - if (!this.constructor.adapter) { + #schedule(args: any[]) { + if (!(this.constructor as typeof RedwoodJob).adapter) { throw new AdapterNotConfiguredError() } try { - return this.constructor.adapter.schedule(this.payload(args)) - } catch (e) { + return (this.constructor as typeof RedwoodJob).adapter.schedule( + this.payload(args), + ) + } catch (e: any) { throw new SchedulingError( `[RedwoodJob] Exception when scheduling ${this.constructor.name}`, e, From fbfa6972c7836c51502a6e3b18e1a8ff711f57e0 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 8 Jul 2024 16:17:41 -0700 Subject: [PATCH 059/258] Converts node scripts to TS, couple unknowns for Tobbe (marked TODO TOBBE) --- .../{rw-jobs-worker.js => rw-jobs-worker.ts} | 18 +++-- .../jobs/src/bins/{rw-jobs.js => rw-jobs.ts} | 69 ++++++++++++------- packages/jobs/src/core/RedwoodJob.ts | 1 + 3 files changed, 60 insertions(+), 28 deletions(-) rename packages/jobs/src/bins/{rw-jobs-worker.js => rw-jobs-worker.ts} (80%) rename packages/jobs/src/bins/{rw-jobs.js => rw-jobs.ts} (82%) diff --git a/packages/jobs/src/bins/rw-jobs-worker.js b/packages/jobs/src/bins/rw-jobs-worker.ts similarity index 80% rename from packages/jobs/src/bins/rw-jobs-worker.js rename to packages/jobs/src/bins/rw-jobs-worker.ts index 47b256347bcd..3e573d952251 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.js +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node // The process that actually starts an instance of Worker to process jobs. - import process from 'node:process' import { hideBin } from 'yargs/helpers' @@ -9,10 +8,11 @@ import yargs from 'yargs/yargs' import { loadAdapter, loadLogger } from '../core/loaders' import { Worker } from '../core/Worker' +import type { BasicLogger } from '../types' const TITLE_PREFIX = `rw-jobs-worker` -const parseArgs = (argv) => { +const parseArgs = (argv: string[]) => { return yargs(hideBin(argv)) .usage( 'Starts a single RedwoodJob worker to process background jobs\n\nUsage: $0 [options]', @@ -43,7 +43,7 @@ const parseArgs = (argv) => { .help().argv } -const setProcessTitle = ({ id, queue }) => { +const setProcessTitle = ({ id, queue }: { id: string; queue: string }) => { // set the process title let title = TITLE_PREFIX if (queue) { @@ -54,7 +54,13 @@ const setProcessTitle = ({ id, queue }) => { process.title = title } -const setupSignals = ({ worker, logger }) => { +const setupSignals = ({ + worker, + logger, +}: { + worker: Worker + logger: BasicLogger +}) => { // if the parent itself receives a ctrl-c it'll pass that to the workers. // workers will exit gracefully by setting `forever` to `false` which will tell // it not to pick up a new job when done with the current one @@ -77,6 +83,8 @@ const setupSignals = ({ worker, logger }) => { } const main = async () => { + // TODO TOBBE For some reason the `parseArgs` type reports that it is only returning the single letter option flags as keys in this object (i, q, c, o), but it does return the alias names as well (id, queue, clear, workoff) I'd rather use the full alias names here as it's much easier to understand what the values are for. + // @ts-ignore const { id, queue, clear, workoff } = parseArgs(process.argv) setProcessTitle({ id, queue }) @@ -84,7 +92,7 @@ const main = async () => { let adapter try { - adapter = await loadAdapter(logger) + adapter = await loadAdapter() } catch (e) { logger.error(e) process.exit(1) diff --git a/packages/jobs/src/bins/rw-jobs.js b/packages/jobs/src/bins/rw-jobs.ts similarity index 82% rename from packages/jobs/src/bins/rw-jobs.js rename to packages/jobs/src/bins/rw-jobs.ts index 6fdf99e7fb71..071c31a27d9b 100755 --- a/packages/jobs/src/bins/rw-jobs.js +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -3,6 +3,7 @@ // Coordinates the worker processes: running attached in [work] mode or // detaching in [start] mode. +import type { ChildProcess } from 'node:child_process' import { fork, exec } from 'node:child_process' import path from 'node:path' import process from 'node:process' @@ -14,13 +15,16 @@ import yargs from 'yargs/yargs' import { loadEnvFiles } from '@redwoodjs/cli/dist/lib/loadEnvFiles' import { loadLogger } from '../core/loaders' +import type { BasicLogger } from '../types' + +export type WorkerConfig = Array<[string | null, number]> // [queue, id] loadEnvFiles() process.title = 'rw-jobs' -const parseArgs = (argv) => { - const parsed = yargs(hideBin(argv)) +const parseArgs = (argv: string[]) => { + const parsed: Record = yargs(hideBin(argv)) .usage( 'Starts the RedwoodJob runner to process background jobs\n\nUsage: $0 [options]', ) @@ -77,28 +81,28 @@ const parseArgs = (argv) => { ) .help().argv - return { numWorkers: parsed.n, command: parsed._[0] } + return { workerDef: parsed.n, command: parsed._[0] } } -const buildWorkerConfig = (numWorkers) => { +const buildWorkerConfig = (workerDef: string): WorkerConfig => { // Builds up an array of arrays, with queue name and id: // `-n default:2,email:1` => [ ['default', 0], ['default', 1], ['email', 0] ] // If only given a number of workers then queue name is null (all queues): // `-n 2` => [ [null, 0], [null, 1] ] - let workers = [] + const workers: WorkerConfig = [] // default to one worker for commands that don't specify - if (!numWorkers) { - numWorkers = '1' + if (!workerDef) { + workerDef = '1' } // if only a number was given, convert it to a nameless worker: `2` => `:2` - if (!isNaN(parseInt(numWorkers))) { - numWorkers = `:${numWorkers}` + if (!isNaN(parseInt(workerDef))) { + workerDef = `:${workerDef}` } // split the queue:num pairs and build the workers array - numWorkers.split(',').forEach((count) => { + workerDef.split(',').forEach((count: string) => { const [queue, num] = count.split(':') for (let i = 0; i < parseInt(num); i++) { workers.push([queue || null, i]) @@ -113,12 +117,17 @@ const startWorkers = ({ detach = false, workoff = false, logger, +}: { + workerConfig: WorkerConfig + detach?: boolean + workoff?: boolean + logger: BasicLogger }) => { logger.warn(`Starting ${workerConfig.length} worker(s)...`) return workerConfig.map(([queue, id]) => { // list of args to send to the forked worker script - const workerArgs = ['--id', id] + const workerArgs: string[] = ['--id', id.toString()] // add the queue name if present if (queue) { @@ -148,7 +157,13 @@ const startWorkers = ({ }) } -const signalSetup = ({ workers, logger }) => { +const signalSetup = ({ + workers, + logger, +}: { + workers: Array + logger: BasicLogger +}) => { // Keep track of how many times the user has pressed ctrl-c let sigtermCount = 0 @@ -172,19 +187,19 @@ const signalSetup = ({ workers, logger }) => { } // Find the process id of a worker by its title -const findProcessId = async (proc) => { +const findProcessId = async (name: string): Promise => { return new Promise(function (resolve, reject) { const plat = process.platform const cmd = plat === 'win32' ? 'tasklist' : plat === 'darwin' - ? 'ps -ax | grep ' + proc + ? 'ps -ax | grep ' + name : plat === 'linux' ? 'ps -A' : '' - if (cmd === '' || proc === '') { - resolve(false) + if (cmd === '' || name === '') { + resolve(null) } exec(cmd, function (err, stdout) { if (err) { @@ -199,7 +214,7 @@ const findProcessId = async (proc) => { return true }) if (matches.length === 0) { - resolve(false) + resolve(null) } else { resolve(parseInt(matches[0].split(' ')[0])) } @@ -208,7 +223,15 @@ const findProcessId = async (proc) => { } // TODO add support for stopping with SIGTERM or SIGKILL? -const stopWorkers = async ({ workerConfig, signal = 'SIGINT', logger }) => { +const stopWorkers = async ({ + workerConfig, + signal = 'SIGINT', + logger, +}: { + workerConfig: WorkerConfig + signal: string + logger: BasicLogger +}) => { logger.warn( `Stopping ${workerConfig.length} worker(s) gracefully (${signal})...`, ) @@ -234,14 +257,14 @@ const stopWorkers = async ({ workerConfig, signal = 'SIGINT', logger }) => { } } -const clearQueue = ({ logger }) => { +const clearQueue = ({ logger }: { logger: BasicLogger }) => { logger.warn(`Starting worker to clear job queue...`) fork(path.join(__dirname, 'worker.js'), ['--clear']) } const main = async () => { - const { numWorkers, command } = parseArgs(process.argv) - const workerConfig = buildWorkerConfig(numWorkers) + const { workerDef, command } = parseArgs(process.argv) + const workerConfig = buildWorkerConfig(workerDef) const logger = await loadLogger() logger.warn(`Starting RedwoodJob Runner at ${new Date().toISOString()}...`) @@ -251,7 +274,7 @@ const main = async () => { startWorkers({ workerConfig, detach: true, logger }) return process.exit(0) case 'restart': - await stopWorkers({ workerConfig, signal: 2, logger }) + await stopWorkers({ workerConfig, signal: 'SIGINT', logger }) startWorkers({ workerConfig, detach: true, logger }) return process.exit(0) case 'work': @@ -267,7 +290,7 @@ const main = async () => { case 'stop': return await stopWorkers({ workerConfig, signal: 'SIGINT', logger }) case 'clear': - return clearQueue() + return clearQueue({ logger }) } } diff --git a/packages/jobs/src/core/RedwoodJob.ts b/packages/jobs/src/core/RedwoodJob.ts index 055710e7395a..975fe480a103 100644 --- a/packages/jobs/src/core/RedwoodJob.ts +++ b/packages/jobs/src/core/RedwoodJob.ts @@ -132,6 +132,7 @@ export class RedwoodJob { } // Must be implemented by the subclass + // TODO TOBBE here's that argument that stopped TS complaining, but you mentioned a generic? The user defines this method in their own subclass, so they can do whatever they want with arguments/types in their own job. The performNow() and performLater() functions above just forward whatever arguments they received to this function perform(..._args: any[]) { throw new PerformNotImplementedError() } From 71e812dee615ce1e8914b562adbfff8f1f96fee7 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 9 Jul 2024 15:48:42 -0700 Subject: [PATCH 060/258] Have rw-log-formatter listen for SIGINT and exit with a 0 (default exit is code 129 which makes execa blow up) --- packages/api-server/src/logFormatter/bin.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/api-server/src/logFormatter/bin.ts b/packages/api-server/src/logFormatter/bin.ts index e3695b27f706..c677fe67b759 100644 --- a/packages/api-server/src/logFormatter/bin.ts +++ b/packages/api-server/src/logFormatter/bin.ts @@ -6,3 +6,9 @@ const input = process.stdin const output = process.stdout input.pipe(split(LogFormatter())).pipe(output) + +// assume that receiving a SIGINT (Ctrl-C) is a normal event, so don't exit with +// a 129 error code, which makes execa blow up. Just return a nice quiet 0. +process.on('SIGINT', () => { + process.exit(0) +}) From a845a87a87af48d2a36e5da98ff872260f194b85 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 9 Jul 2024 15:49:02 -0700 Subject: [PATCH 061/258] Remove "Received SIGINT signal, existing..." output message from telemetry --- packages/cli/src/telemetry/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/telemetry/index.js b/packages/cli/src/telemetry/index.js index b8160d21e0a5..4112deec144f 100644 --- a/packages/cli/src/telemetry/index.js +++ b/packages/cli/src/telemetry/index.js @@ -78,7 +78,6 @@ export async function startTelemetry() { for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) { process.on(signal, () => { if (process.listenerCount(signal) === 1) { - console.log(`Received ${signal} signal, exiting...`) process.exit() } }) From b932013a0c727eb1a803f7226425e38456707ba6 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 9 Jul 2024 15:59:52 -0700 Subject: [PATCH 062/258] Add `yarn rw jobs` cli command to invoke `yarn rw-jobs` --- packages/cli/src/commands/jobs.js | 22 +++++++++++++++ packages/cli/src/commands/jobsHandler.js | 36 ++++++++++++++++++++++++ packages/cli/src/index.js | 2 ++ 3 files changed, 60 insertions(+) create mode 100644 packages/cli/src/commands/jobs.js create mode 100644 packages/cli/src/commands/jobsHandler.js diff --git a/packages/cli/src/commands/jobs.js b/packages/cli/src/commands/jobs.js new file mode 100644 index 000000000000..b7e15face14f --- /dev/null +++ b/packages/cli/src/commands/jobs.js @@ -0,0 +1,22 @@ +export const command = 'jobs' +export const description = + 'Starts the RedwoodJob runner to process background jobs' + +export const builder = (yargs) => { + // Disable yargs parsing of commands and options because it's forwarded + // to rw-jobs + yargs + .strictOptions(false) + .strictCommands(false) + .strict(false) + .parserConfiguration({ + 'camel-case-expansion': false, + }) + .help(false) + .version(false) +} + +export const handler = async (options) => { + const { handler } = await import('./jobsHandler.js') + return handler(options) +} diff --git a/packages/cli/src/commands/jobsHandler.js b/packages/cli/src/commands/jobsHandler.js new file mode 100644 index 000000000000..1d92f6de1e7a --- /dev/null +++ b/packages/cli/src/commands/jobsHandler.js @@ -0,0 +1,36 @@ +import execa from 'execa' + +import { getPaths } from '../lib/index' + +export const handler = async ({ + _, + $0: _rw, + commands: _commands, + ...options +}) => { + const args = [_.pop()] + + for (const [name, value] of Object.entries(options)) { + // Allow both long and short form commands, e.g. --name and -n + args.push(name.length > 1 ? `--${name}` : `-${name}`) + args.push(value) + } + + let command = `yarn rw-jobs ${args.join(' ')}` + const originalLogLevel = process.env.LOG_LEVEL + process.env.LOG_LEVEL = originalLogLevel || 'warn' + + // make logs look nice in development (assume any env that's not prod is dev) + // that includes showing more verbose logs unless the user set otherwise + if (process.env.NODE_ENV !== 'production') { + command += ' | yarn rw-log-formatter' + process.env.LOG_LEVEL = originalLogLevel || 'debug' + } + + execa.commandSync(command, { + shell: true, + cwd: getPaths().base, + stdio: 'inherit', + cleanup: true, + }) +} diff --git a/packages/cli/src/index.js b/packages/cli/src/index.js index c57e899f4c3d..cd2dc2502650 100644 --- a/packages/cli/src/index.js +++ b/packages/cli/src/index.js @@ -20,6 +20,7 @@ import * as execCommand from './commands/exec' import * as experimentalCommand from './commands/experimental' import * as generateCommand from './commands/generate' import * as infoCommand from './commands/info' +import * as jobsCommand from './commands/jobs' import * as lintCommand from './commands/lint' import * as prerenderCommand from './commands/prerender' import * as prismaCommand from './commands/prisma' @@ -210,6 +211,7 @@ async function runYargs() { .command(experimentalCommand) .command(generateCommand) .command(infoCommand) + .command(jobsCommand) .command(lintCommand) .command(prerenderCommand) .command(prismaCommand) From bf9272d0dbd81375b7548c726a0bbec8b95966e0 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 10 Jul 2024 06:39:43 +0200 Subject: [PATCH 063/258] Stricter types --- packages/jobs/src/adapters/BaseAdapter.ts | 6 +++--- packages/jobs/src/adapters/PrismaAdapter.ts | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/jobs/src/adapters/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter.ts index fddc8d4db256..c5344033e323 100644 --- a/packages/jobs/src/adapters/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter.ts @@ -37,11 +37,11 @@ export interface BaseAdapterOptions { logger?: BasicLogger } -export abstract class BaseAdapter { - options: any +export abstract class BaseAdapter { + options: TOptions logger: BasicLogger - constructor(options: BaseAdapterOptions) { + constructor(options: TOptions) { this.options = options this.logger = options?.logger || console } diff --git a/packages/jobs/src/adapters/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter.ts index c57fc44b4c19..33db492b7ac0 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter.ts @@ -102,7 +102,9 @@ export class PrismaAdapter extends BaseAdapter { // The act of locking a job is dependant on the DB server, so we'll run some // raw SQL to do it in each case—Prisma doesn't provide enough flexibility // in their generated code to do this in a DB-agnostic way. - // TODO there may be more optimzed versions of the locking queries in Postgres and MySQL, this.options.db._activeProvider returns the provider name + // TODO: there may be more optimized versions of the locking queries in + // Postgres and MySQL, this.options.db._activeProvider returns the provider + // name async find({ processName, maxRuntime, From fde562f23ed472c5390ef4ec31c75f75b3e0f2c9 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 10 Jul 2024 08:17:32 +0200 Subject: [PATCH 064/258] loadEnvFiles --- .vscode/settings.json | 1 + packages/cli-helpers/package.json | 15 ++++- packages/cli-helpers/src/lib/loadEnvFiles.ts | 71 ++++++++++++++++++++ packages/cli-helpers/tsconfig.build.json | 1 - packages/cli-helpers/tsconfig.json | 1 + packages/cli/src/lib/loadEnvFiles.js | 1 + packages/jobs/build.mts | 9 +++ packages/jobs/src/adapters/BaseAdapter.ts | 4 +- packages/jobs/src/adapters/PrismaAdapter.ts | 3 +- packages/jobs/src/bins/rw-jobs.ts | 6 +- packages/jobs/tsconfig.json | 8 ++- yarn.lock | 15 ++++- 12 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 packages/cli-helpers/src/lib/loadEnvFiles.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 65eceb57b6b3..38a6021bc948 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,6 +26,7 @@ "baremetal", "bazinga", "corepack", + "daemonized", "envinfo", "execa", "Fastify", diff --git a/packages/cli-helpers/package.json b/packages/cli-helpers/package.json index 3b35e7a3ddee..1aa8d7e0ab44 100644 --- a/packages/cli-helpers/package.json +++ b/packages/cli-helpers/package.json @@ -9,9 +9,16 @@ "license": "MIT", "type": "module", "exports": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "default": "./dist/index.cjs" + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "default": "./dist/index.cjs" + }, + "./loadEnvFiles": { + "types": "./dist/src/loadEnvFiles.d.ts", + "import": "./dist/src/loadEnvFiles.mjs", + "default": "./dist/src/loadEnvFiles.cjs" + } }, "types": "./dist/index.d.ts", "files": [ @@ -33,6 +40,7 @@ "@redwoodjs/telemetry": "workspace:*", "chalk": "4.1.2", "dotenv": "16.4.5", + "dotenv-defaults": "5.0.2", "execa": "5.1.1", "listr2": "6.6.1", "lodash": "4.17.21", @@ -43,6 +51,7 @@ "terminal-link": "2.1.1" }, "devDependencies": { + "@types/dotenv-defaults": "^2.0.4", "@types/lodash": "4.17.5", "@types/pascalcase": "1.0.3", "@types/yargs": "17.0.32", diff --git a/packages/cli-helpers/src/lib/loadEnvFiles.ts b/packages/cli-helpers/src/lib/loadEnvFiles.ts new file mode 100644 index 000000000000..278e9e60a3e8 --- /dev/null +++ b/packages/cli-helpers/src/lib/loadEnvFiles.ts @@ -0,0 +1,71 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' + +import { config as dotenvConfig } from 'dotenv' +import { config as dotenvDefaultsConfig } from 'dotenv-defaults' +import { hideBin, Parser } from 'yargs/helpers' + +import { getPaths } from '@redwoodjs/project-config' + +export function loadEnvFiles() { + if (process.env.REDWOOD_ENV_FILES_LOADED) { + return + } + + const { base } = getPaths() + + loadDefaultEnvFiles(base) + loadNodeEnvDerivedEnvFile(base) + + const { loadEnvFiles } = Parser.default(hideBin(process.argv), { + array: ['load-env-files'], + default: { + loadEnvFiles: [], + }, + }) + + if (loadEnvFiles.length > 0) { + loadUserSpecifiedEnvFiles(base, loadEnvFiles) + } + + process.env.REDWOOD_ENV_FILES_LOADED = 'true' +} + +export function loadDefaultEnvFiles(cwd: string) { + dotenvDefaultsConfig({ + path: path.join(cwd, '.env'), + defaults: path.join(cwd, '.env.defaults'), + // @ts-expect-error - the type definitions are too old. They use dotenv v8, + // dotenv-default uses v14 + multiline: true, + }) +} + +export function loadNodeEnvDerivedEnvFile(cwd: string) { + if (!process.env.NODE_ENV) { + return + } + + const nodeEnvDerivedEnvFilePath = path.join( + cwd, + `.env.${process.env.NODE_ENV}`, + ) + if (!fs.existsSync(nodeEnvDerivedEnvFilePath)) { + return + } + + dotenvConfig({ path: nodeEnvDerivedEnvFilePath, override: true }) +} + +export function loadUserSpecifiedEnvFiles(cwd: string, loadEnvFiles: string[]) { + for (const suffix of loadEnvFiles) { + const envPath = path.join(cwd, `.env.${suffix}`) + if (!fs.existsSync(envPath)) { + throw new Error( + `Couldn't find an .env file at '${envPath}' as specified by '--load-env-files'`, + ) + } + + dotenvConfig({ path: envPath, override: true }) + } +} diff --git a/packages/cli-helpers/tsconfig.build.json b/packages/cli-helpers/tsconfig.build.json index 34f2ac98ff3f..fa200e86c58f 100644 --- a/packages/cli-helpers/tsconfig.build.json +++ b/packages/cli-helpers/tsconfig.build.json @@ -3,7 +3,6 @@ "compilerOptions": { "moduleResolution": "NodeNext", "module": "NodeNext", - "baseUrl": ".", "rootDir": "src", "outDir": "dist", }, diff --git a/packages/cli-helpers/tsconfig.json b/packages/cli-helpers/tsconfig.json index 8e96c16e435f..c17ae44a61d8 100644 --- a/packages/cli-helpers/tsconfig.json +++ b/packages/cli-helpers/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "moduleResolution": "NodeNext", "module": "NodeNext", + "baseUrl": ".", "rootDir": "src", "outDir": "dist" }, diff --git a/packages/cli/src/lib/loadEnvFiles.js b/packages/cli/src/lib/loadEnvFiles.js index cf77c3666f30..e6e435fe0355 100644 --- a/packages/cli/src/lib/loadEnvFiles.js +++ b/packages/cli/src/lib/loadEnvFiles.js @@ -25,6 +25,7 @@ export function loadEnvFiles() { loadEnvFiles: [], }, }) + if (loadEnvFiles.length > 0) { loadUserSpecifiedEnvFiles(base, loadEnvFiles) } diff --git a/packages/jobs/build.mts b/packages/jobs/build.mts index ad9464af1074..df7b613cf130 100644 --- a/packages/jobs/build.mts +++ b/packages/jobs/build.mts @@ -1,7 +1,16 @@ import { build, defaultBuildOptions } from '@redwoodjs/framework-tools' +// CJS build await build({ buildOptions: { ...defaultBuildOptions, }, }) + +// ESM build +// await build({ +// buildOptions: { +// ...defaultBuildOptions, +// format: 'esm', +// }, +// }) diff --git a/packages/jobs/src/adapters/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter.ts index c5344033e323..3f0a4b170431 100644 --- a/packages/jobs/src/adapters/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter.ts @@ -37,7 +37,9 @@ export interface BaseAdapterOptions { logger?: BasicLogger } -export abstract class BaseAdapter { +export abstract class BaseAdapter< + TOptions extends BaseAdapterOptions = BaseAdapterOptions, +> { options: TOptions logger: BasicLogger diff --git a/packages/jobs/src/adapters/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter.ts index 33db492b7ac0..a1c2bfacc2d7 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter.ts @@ -68,7 +68,7 @@ interface FailureData { runAt: Date | null } -export class PrismaAdapter extends BaseAdapter { +export class PrismaAdapter extends BaseAdapter { db: PrismaClient model: string accessor: PrismaClient[keyof PrismaClient] @@ -234,6 +234,7 @@ export class PrismaAdapter extends BaseAdapter { } async clear() { + this.options.db.$disconnect() return await this.accessor.deleteMany() } diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index 071c31a27d9b..726b3bc2457a 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -5,14 +5,14 @@ import type { ChildProcess } from 'node:child_process' import { fork, exec } from 'node:child_process' -import path from 'node:path' -import process from 'node:process' +import * as path from 'node:path' +import * as process from 'node:process' import { setTimeout } from 'node:timers' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' -import { loadEnvFiles } from '@redwoodjs/cli/dist/lib/loadEnvFiles' +import { loadEnvFiles } from '@redwoodjs/cli-helpers/dist/lib/loadEnvFiles.js' import { loadLogger } from '../core/loaders' import type { BasicLogger } from '../types' diff --git a/packages/jobs/tsconfig.json b/packages/jobs/tsconfig.json index 1259c1abbf06..be2bbd94092e 100644 --- a/packages/jobs/tsconfig.json +++ b/packages/jobs/tsconfig.json @@ -3,7 +3,11 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "allowJs": true }, - "include": ["src/**/*"] + "include": ["src/**/*"], + "references": [ + { "path": "../babel-config" }, + { "path": "../cli-helpers" }, + { "path": "../project-config" }, + ] } diff --git a/yarn.lock b/yarn.lock index 4b5eb7d175e6..05d79c6a559a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7858,11 +7858,13 @@ __metadata: "@opentelemetry/api": "npm:1.8.0" "@redwoodjs/project-config": "workspace:*" "@redwoodjs/telemetry": "workspace:*" + "@types/dotenv-defaults": "npm:^2.0.4" "@types/lodash": "npm:4.17.5" "@types/pascalcase": "npm:1.0.3" "@types/yargs": "npm:17.0.32" chalk: "npm:4.1.2" dotenv: "npm:16.4.5" + dotenv-defaults: "npm:5.0.2" execa: "npm:5.1.1" listr2: "npm:6.6.1" lodash: "npm:4.17.21" @@ -8325,7 +8327,8 @@ __metadata: typescript: "npm:5.4.5" vitest: "npm:1.6.0" bin: - rw-jobs: ./dist/bins/runner.js + rw-jobs: ./dist/bins/rw-jobs.js + rw-jobs-worker: ./dist/bins/rw-jobs-worker.js languageName: unknown linkType: soft @@ -10767,6 +10770,16 @@ __metadata: languageName: node linkType: hard +"@types/dotenv-defaults@npm:^2.0.4": + version: 2.0.4 + resolution: "@types/dotenv-defaults@npm:2.0.4" + dependencies: + "@types/node": "npm:*" + dotenv: "npm:^8.2.0" + checksum: 10c0/7d7ebdd696318d5f9a510d1d0b5fa262e240a377056d6ce7ed8984fb222d472732bf27f8d16726879531706030f1aa0879e65e711dbda17ad4d0be138d9d118b + languageName: node + linkType: hard + "@types/ejs@npm:^3.1.1": version: 3.1.2 resolution: "@types/ejs@npm:3.1.2" From ba073f8a6519ba99ad4395cd7b04ebf9be3fb855 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 10 Jul 2024 18:31:38 +0200 Subject: [PATCH 065/258] chore(cli-helpers): loadEnvFiles cleanup (#10935) --- packages/cli-helpers/package.json | 1 - packages/cli-helpers/src/lib/loadEnvFiles.ts | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/cli-helpers/package.json b/packages/cli-helpers/package.json index ae06e8a73a01..fbb941d90913 100644 --- a/packages/cli-helpers/package.json +++ b/packages/cli-helpers/package.json @@ -56,7 +56,6 @@ "@types/lodash": "4.17.5", "@types/pascalcase": "1.0.3", "@types/yargs": "17.0.32", - "dotenv-defaults": "5.0.2", "tsx": "4.15.6", "typescript": "5.4.5", "vitest": "1.6.0" diff --git a/packages/cli-helpers/src/lib/loadEnvFiles.ts b/packages/cli-helpers/src/lib/loadEnvFiles.ts index d013c2946753..95f70afd8d39 100644 --- a/packages/cli-helpers/src/lib/loadEnvFiles.ts +++ b/packages/cli-helpers/src/lib/loadEnvFiles.ts @@ -1,8 +1,8 @@ -import path from 'path' +import fs from 'node:fs' +import path from 'node:path' import { config as dotenvConfig } from 'dotenv' import { config as dotenvDefaultsConfig } from 'dotenv-defaults' -import fs from 'fs-extra' import { hideBin, Parser } from 'yargs/helpers' import { getPaths } from '@redwoodjs/project-config' @@ -60,7 +60,7 @@ export function loadNodeEnvDerivedEnvFile(cwd: string) { export function loadUserSpecifiedEnvFiles(cwd: string, loadEnvFiles: string[]) { for (const suffix of loadEnvFiles) { const envPath = path.join(cwd, `.env.${suffix}`) - if (!fs.pathExistsSync(envPath)) { + if (!fs.existsSync(envPath)) { throw new Error( `Couldn't find an .env file at '${envPath}' as specified by '--load-env-files'`, ) From 621053277632681fab9d0700517c114836e698ed Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 10 Jul 2024 18:45:27 +0200 Subject: [PATCH 066/258] Remove accidentally added code --- packages/jobs/src/adapters/PrismaAdapter.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/jobs/src/adapters/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter.ts index a1c2bfacc2d7..909f9c765511 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter.ts @@ -234,7 +234,6 @@ export class PrismaAdapter extends BaseAdapter { } async clear() { - this.options.db.$disconnect() return await this.accessor.deleteMany() } From bf9f09342be97fc92f7d1a93f7995c940558bff7 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 10 Jul 2024 18:49:31 +0200 Subject: [PATCH 067/258] Undo random code change --- packages/cli/src/lib/loadEnvFiles.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/lib/loadEnvFiles.js b/packages/cli/src/lib/loadEnvFiles.js index e6e435fe0355..cf77c3666f30 100644 --- a/packages/cli/src/lib/loadEnvFiles.js +++ b/packages/cli/src/lib/loadEnvFiles.js @@ -25,7 +25,6 @@ export function loadEnvFiles() { loadEnvFiles: [], }, }) - if (loadEnvFiles.length > 0) { loadUserSpecifiedEnvFiles(base, loadEnvFiles) } From 33855d0d4260036368b19e7b1759a94a4e166fde Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 10 Jul 2024 13:55:54 -0700 Subject: [PATCH 068/258] Adds `yarn rw setup jobs` command --- packages/cli/src/commands/setup/jobs/jobs.js | 32 ++++++++ .../src/commands/setup/jobs/jobsHandler.js | 79 +++++++++++++++++++ .../setup/jobs/templates/jobs.ts.template | 18 +++++ 3 files changed, 129 insertions(+) create mode 100644 packages/cli/src/commands/setup/jobs/jobs.js create mode 100644 packages/cli/src/commands/setup/jobs/jobsHandler.js create mode 100644 packages/cli/src/commands/setup/jobs/templates/jobs.ts.template diff --git a/packages/cli/src/commands/setup/jobs/jobs.js b/packages/cli/src/commands/setup/jobs/jobs.js new file mode 100644 index 000000000000..c1384861a71e --- /dev/null +++ b/packages/cli/src/commands/setup/jobs/jobs.js @@ -0,0 +1,32 @@ +import terminalLink from 'terminal-link' + +import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' + +export const command = 'jobs' +export const description = + 'Sets up the config file and parent directory for background jobs' + +export const builder = (yargs) => { + yargs + .option('force', { + alias: 'f', + default: false, + description: 'Overwrite existing files', + type: 'boolean', + }) + .epilogue( + `Also see the ${terminalLink( + 'Redwood CLI Reference', + 'https://redwoodjs.com/docs/cli-commands#setup-jobs', + )}`, + ) +} + +export const handler = async (options) => { + recordTelemetryAttributes({ + command: 'setup jobs', + force: options.force, + }) + const { handler } = await import('./jobsHandler.js') + return handler(options) +} diff --git a/packages/cli/src/commands/setup/jobs/jobsHandler.js b/packages/cli/src/commands/setup/jobs/jobsHandler.js new file mode 100644 index 000000000000..b9fcddf94b5f --- /dev/null +++ b/packages/cli/src/commands/setup/jobs/jobsHandler.js @@ -0,0 +1,79 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' + +import chalk from 'chalk' +import { Listr } from 'listr2' + +import { getPaths, transformTSToJS, writeFile } from '../../../lib' +import c from '../../../lib/colors' +import { isTypeScriptProject } from '../../../lib/project' + +const tasks = ({ force }) => { + return new Listr( + [ + { + title: 'Creating config file in api/src/lib...', + task: async () => { + const isTs = isTypeScriptProject() + const outputExtension = isTs ? 'ts' : 'js' + const outputPath = path.join( + getPaths().api.lib, + `jobs.${outputExtension}`, + ) + let template = fs + .readFileSync( + path.resolve(__dirname, 'templates', 'jobs.ts.template'), + ) + .toString() + + if (!isTs) { + template = await transformTSToJS(outputPath, template) + } + + writeFile(outputPath, template, { + overwriteExisting: force, + }) + }, + }, + { + title: 'Creating jobs dir at api/src/jobs...', + task: () => { + try { + fs.mkdirSync(getPaths().api.jobs) + } catch (e) { + // ignore directory already existing + if (!e.message.match('file already exists')) { + throw new Error(e) + } + } + writeFile(path.join(getPaths().api.jobs, '.keep'), '', { + overwriteExisting: force, + }) + }, + }, + { + title: 'One more thing...', + task: (_ctx, task) => { + task.title = `One more thing... + ${c.green('Background jobs configured!\n')} + ${'Generate jobs with:'} ${c.warning('yarn rw g job ')} + ${'Execute jobs with:'} ${c.warning('yarn rw jobs work\n')} + ${'Check out the docs for more info:'} + ${chalk.hex('#e8e8e8')('https://docs.redwoodjs.com/docs/background-jobs')} + ` + }, + }, + ], + { rendererOptions: { collapseSubtasks: false }, errorOnExist: true }, + ) +} + +export const handler = async ({ force }) => { + const t = tasks({ force }) + + try { + await t.run() + } catch (e) { + console.log(c.error(e.message)) + } +} diff --git a/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template b/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template new file mode 100644 index 000000000000..3c5a50c0d01e --- /dev/null +++ b/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template @@ -0,0 +1,18 @@ +// Setup for background jobs. Jobs themselves live in api/src/jobs +// Execute jobs in dev with `yarn rw jobs work` +// See https://docs.redwoodjs.com/docs/background-jobs + +import { PrismaAdapter, RedwoodJob } from '@redwoodjs/jobs' + +import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' + +// Must export `adapter` so that the job runner can use it to look for jobs +export const adapter = new PrismaAdapter({ db, logger }) + +// Global config for all jobs, can override on a per-job basis +RedwoodJob.config({ adapter, logger }) + +// Export instances of all your jobs to make them easy to import and use: +// export const jobs = { sample: new SampleJob(), email: new EmailJob() } +export const jobs = {} From 06c910b887bbad238036f5a9986dbec10f654f8a Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 10 Jul 2024 13:57:10 -0700 Subject: [PATCH 069/258] Adds @ts-expect-error when setting process.title --- packages/jobs/src/bins/rw-jobs-worker.ts | 4 +++- packages/jobs/src/bins/rw-jobs.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index 3e573d952251..99803785b25a 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node // The process that actually starts an instance of Worker to process jobs. -import process from 'node:process' +import * as process from 'node:process' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' @@ -51,6 +51,8 @@ const setProcessTitle = ({ id, queue }: { id: string; queue: string }) => { } else { title += `.${id}` } + + // @ts-expect-error - TS will claim this is read-only process.title = title } diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index 726b3bc2457a..9b2d876d8f49 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -21,6 +21,7 @@ export type WorkerConfig = Array<[string | null, number]> // [queue, id] loadEnvFiles() +// @ts-expect-error - TS will claim this is read-only process.title = 'rw-jobs' const parseArgs = (argv: string[]) => { From ea7ac962582b595dcb9582fe55fa6451f5454780 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 10 Jul 2024 14:08:52 -0700 Subject: [PATCH 070/258] Be sure to include ENV when forking workers --- packages/jobs/src/bins/rw-jobs.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index 9b2d876d8f49..7dbccce8bcfa 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -145,6 +145,7 @@ const startWorkers = ({ const worker = fork(path.join(__dirname, 'rw-jobs-worker.js'), workerArgs, { detached: detach, stdio: detach ? 'ignore' : 'inherit', + env: process.env, }) if (detach) { From dff8343035a52b2d585862a2cc030a45f9e27cd0 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 10 Jul 2024 23:41:15 +0200 Subject: [PATCH 071/258] Revert 'import * as' --- packages/jobs/src/bins/rw-jobs.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index 7dbccce8bcfa..b51dd3384246 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -5,8 +5,8 @@ import type { ChildProcess } from 'node:child_process' import { fork, exec } from 'node:child_process' -import * as path from 'node:path' -import * as process from 'node:process' +import path from 'node:path' +import process from 'node:process' import { setTimeout } from 'node:timers' import { hideBin } from 'yargs/helpers' @@ -21,7 +21,6 @@ export type WorkerConfig = Array<[string | null, number]> // [queue, id] loadEnvFiles() -// @ts-expect-error - TS will claim this is read-only process.title = 'rw-jobs' const parseArgs = (argv: string[]) => { From 929016547a068923db1ae5cf5eea83331ff1bd21 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 10 Jul 2024 15:13:39 -0700 Subject: [PATCH 072/258] Don't import * as it makes process.title read-only --- packages/jobs/src/bins/rw-jobs-worker.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index 99803785b25a..ab588014f53d 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node // The process that actually starts an instance of Worker to process jobs. -import * as process from 'node:process' +import process from 'node:process' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' @@ -52,7 +52,6 @@ const setProcessTitle = ({ id, queue }: { id: string; queue: string }) => { title += `.${id}` } - // @ts-expect-error - TS will claim this is read-only process.title = title } From c8161cd00242fcbf72d39dfb5d4d476f7eb0d254 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 10 Jul 2024 15:17:23 -0700 Subject: [PATCH 073/258] Update error message when api/src/lib/jobs.js not found --- packages/jobs/src/core/errors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jobs/src/core/errors.ts b/packages/jobs/src/core/errors.ts index 5f1de84a5dc5..9da6cf7789fb 100644 --- a/packages/jobs/src/core/errors.ts +++ b/packages/jobs/src/core/errors.ts @@ -67,7 +67,7 @@ export class JobExportNotFoundError extends RedwoodJobError { export class JobsLibNotFoundError extends RedwoodJobError { constructor() { super( - 'api/src/lib/jobs.js not found. Create this file and export `adapter` for the job runner to use', + 'api/src/lib/jobs.js not found. Run `yarn rw setup jobs` to create this file and configure background jobs', ) } } From aa1269bb71722b57b2520d6589f5a75b2455a231 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 10 Jul 2024 15:35:55 -0700 Subject: [PATCH 074/258] Add BackgroundJob database table during setup command --- .../src/commands/setup/jobs/jobsHandler.js | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/packages/cli/src/commands/setup/jobs/jobsHandler.js b/packages/cli/src/commands/setup/jobs/jobsHandler.js index b9fcddf94b5f..b66f77c7424f 100644 --- a/packages/cli/src/commands/setup/jobs/jobsHandler.js +++ b/packages/cli/src/commands/setup/jobs/jobsHandler.js @@ -1,16 +1,80 @@ import * as fs from 'node:fs' import * as path from 'node:path' +import { getDMMF } from '@prisma/internals' import chalk from 'chalk' +import execa from 'execa' import { Listr } from 'listr2' import { getPaths, transformTSToJS, writeFile } from '../../../lib' import c from '../../../lib/colors' import { isTypeScriptProject } from '../../../lib/project' +const MODEL_SCHEMA = ` +model BackgroundJob { + id Int @id @default(autoincrement()) + attempts Int @default(0) + handler String + queue String + priority Int + runAt DateTime? + lockedAt DateTime? + lockedBy String? + lastError String? + failedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +` + +const getModelNames = async () => { + const schema = await getDMMF({ datamodelPath: getPaths().api.dbSchema }) + + return schema.datamodel.models.map((model) => model.name) +} + +const addModel = () => { + const schema = fs.readFileSync(getPaths().api.dbSchema, 'utf-8') + + const schemaWithUser = schema + MODEL_SCHEMA + + fs.writeFileSync(getPaths().api.dbSchema, schemaWithUser) +} + const tasks = ({ force }) => { + let skipSchema = false + return new Listr( [ + { + title: 'Creating job model...', + task: async (_ctx, task) => { + if ((await getModelNames()).includes('BackgroundJob')) { + skipSchema = true + task.skip('Model already exists, skipping creation') + } else { + addModel() + } + }, + }, + { + title: 'Migrating database...', + task: async (_ctx, task) => { + if (skipSchema) { + task.skip('Model already exists, skipping migration') + } else { + execa.sync( + 'yarn rw prisma migrate dev', + ['--name', 'create-background-jobs'], + { + shell: true, + cwd: getPaths().base, + stdio: 'inherit', + }, + ) + } + }, + }, { title: 'Creating config file in api/src/lib...', task: async () => { From 8106bf49abcc6f6595f6b1f777cf85231d42d82f Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 11 Jul 2024 10:34:49 +0200 Subject: [PATCH 075/258] Support TS jobs --- packages/jobs/src/core/loaders.ts | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/jobs/src/core/loaders.ts b/packages/jobs/src/core/loaders.ts index 424b67e243c7..07cccd64c47d 100644 --- a/packages/jobs/src/core/loaders.ts +++ b/packages/jobs/src/core/loaders.ts @@ -20,12 +20,11 @@ export function makeFilePath(path: string) { return pathToFileURL(path).href } -// Loads the exported adapter from the app's jobs config in api/src/lib/jobs.js +// Loads the exported adapter from the app's jobs config in api/src/lib/jobs.{js,ts} export const loadAdapter = async () => { - if (getPaths().api.jobsConfig) { - const { default: jobsModule } = await import( - makeFilePath(getPaths().api.jobsConfig as string) - ) + const jobsConfigPath = getPaths().api.jobsConfig + if (jobsConfigPath) { + const jobsModule = require(jobsConfigPath) if (jobsModule.adapter) { return jobsModule.adapter } else { @@ -36,32 +35,32 @@ export const loadAdapter = async () => { } } -// Loads the logger from the app's filesystem in api/src/lib/logger.js +// Loads the logger from the app's filesystem in api/src/lib/logger.{js,ts} export const loadLogger = async () => { - if (getPaths().api.logger) { + const loggerPath = getPaths().api.logger + if (loggerPath) { try { - const { default: loggerModule } = await import( - makeFilePath(getPaths().api.logger as string) - ) + const loggerModule = require(loggerPath) return loggerModule.logger } catch (e) { console.warn( - 'Tried to load logger but failed, falling back to console', + 'Tried to load logger but failed, falling back to console\n', e, ) } } + return console } // Loads a job from the app's filesystem in api/src/jobs export const loadJob = async (name: string) => { - const files = fg.sync(`**/${name}.*`, { cwd: getPaths().api.jobs }) + // Specifying {js,ts} extensions, so we don't accidentally try to load .json + // files or similar + const files = fg.sync(`**/${name}.{js,ts}`, { cwd: getPaths().api.jobs }) if (!files[0]) { throw new JobNotFoundError(name) } - const { default: jobModule } = await import( - makeFilePath(path.join(getPaths().api.jobs, files[0])) - ) + const jobModule = require(path.join(getPaths().api.jobs, files[0])) return jobModule } From 003e4e21371ec78e7108da6cb9f87a621e7a9795 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 11 Jul 2024 11:13:50 +0200 Subject: [PATCH 076/258] parseArgs full names --- packages/jobs/src/bins/rw-jobs-worker.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index ab588014f53d..a031bec01af4 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -17,25 +17,25 @@ const parseArgs = (argv: string[]) => { .usage( 'Starts a single RedwoodJob worker to process background jobs\n\nUsage: $0 [options]', ) - .option('i', { - alias: 'id', + .option('id', { + alias: 'i', type: 'number', description: 'The worker ID', default: 0, }) - .option('q', { - alias: 'queue', + .option('queue', { + alias: 'q', type: 'string', description: 'The named queue to work on', }) - .option('o', { - alias: 'workoff', + .option('workoff', { + alias: 'o', type: 'boolean', default: false, description: 'Work off all jobs in the queue and exit', }) - .option('c', { - alias: 'clear', + .option('clear', { + alias: 'c', type: 'boolean', default: false, description: 'Remove all jobs in the queue and exit', @@ -84,9 +84,10 @@ const setupSignals = ({ } const main = async () => { - // TODO TOBBE For some reason the `parseArgs` type reports that it is only returning the single letter option flags as keys in this object (i, q, c, o), but it does return the alias names as well (id, queue, clear, workoff) I'd rather use the full alias names here as it's much easier to understand what the values are for. - // @ts-ignore - const { id, queue, clear, workoff } = parseArgs(process.argv) + const { id, queue, clear, workoff } = await parseArgs(process.argv) + // TODO Rob: I'll let you decide how you want to handle the type errors here + // @ts-expect-error - id is a number, and queue can be undefined + // setProcessTitle wants two strings setProcessTitle({ id, queue }) const logger = await loadLogger() From 0a613ab63fe036c17935a0d569c390d98d349063 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Thu, 11 Jul 2024 11:14:12 +0200 Subject: [PATCH 077/258] Abstract RedwoodJobs --- packages/jobs/src/core/RedwoodJob.ts | 19 ++++++++++++------- .../src/core/__tests__/RedwoodJob.test.js | 4 ++-- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/jobs/src/core/RedwoodJob.ts b/packages/jobs/src/core/RedwoodJob.ts index 975fe480a103..3a65f2345c91 100644 --- a/packages/jobs/src/core/RedwoodJob.ts +++ b/packages/jobs/src/core/RedwoodJob.ts @@ -39,7 +39,7 @@ export interface JobSetOptions { export const DEFAULT_QUEUE = 'default' -export class RedwoodJob { +export abstract class RedwoodJob { // The default queue for all jobs static queue = DEFAULT_QUEUE @@ -63,20 +63,23 @@ export class RedwoodJob { // Class method to schedule a job to run later // const scheduleDetails = RedwoodJob.performLater('foo', 'bar') - static performLater(...args: any[]) { + static performLater(this: new () => T, ...args: any[]) { return new this().performLater(...args) } // Class method to run the job immediately in the current process // const result = RedwoodJob.performNow('foo', 'bar') - static performNow(...args: any[]) { + static performNow(this: new () => T, ...args: any[]) { return new this().performNow(...args) } // Set options on the job before enqueueing it: // const job = RedwoodJob.set({ wait: 300 }) // job.performLater('foo', 'bar') - static set(options: JobSetOptions = {}) { + static set( + this: new (options: JobSetOptions) => T, + options: JobSetOptions = {}, + ) { return new this(options) } @@ -133,9 +136,11 @@ export class RedwoodJob { // Must be implemented by the subclass // TODO TOBBE here's that argument that stopped TS complaining, but you mentioned a generic? The user defines this method in their own subclass, so they can do whatever they want with arguments/types in their own job. The performNow() and performLater() functions above just forward whatever arguments they received to this function - perform(..._args: any[]) { - throw new PerformNotImplementedError() - } + // TODO Rob: What's the correct return type here? Do you expect the perform() method to return something? + abstract perform(..._args: any[]): any + // { + // throw new PerformNotImplementedError() + // } // Returns data sent to the adapter for scheduling payload(args: any[]) { diff --git a/packages/jobs/src/core/__tests__/RedwoodJob.test.js b/packages/jobs/src/core/__tests__/RedwoodJob.test.js index a3236c920d99..6eb74124bce7 100644 --- a/packages/jobs/src/core/__tests__/RedwoodJob.test.js +++ b/packages/jobs/src/core/__tests__/RedwoodJob.test.js @@ -398,7 +398,7 @@ describe('instance performNow()', () => { vi.clearAllMocks() }) - it('throws an error if perform() function is not implemented', async () => { + it.skip('throws an error if perform() function is not implemented', async () => { class TestJob extends RedwoodJob {} const job = new TestJob() @@ -483,7 +483,7 @@ describe('instance performNow()', () => { }) describe('perform()', () => { - it('throws an error if not implemented', () => { + it.skip('throws an error if not implemented', () => { const job = new RedwoodJob() expect(() => job.perform()).toThrow(errors.PerformNotImplementedError) From 4d7280c08ee800cdbbdff938380b068be033cb47 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 11 Jul 2024 13:22:29 -0700 Subject: [PATCH 078/258] Update types for setting process, remove TODO --- packages/jobs/src/bins/rw-jobs-worker.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index a031bec01af4..611f594d06c9 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -1,6 +1,9 @@ #!/usr/bin/env node // The process that actually starts an instance of Worker to process jobs. +// Can be run independently with `yarn rw-jobs-worker` but by default is forked +// by `yarn rw-jobs` and either monitored, or detached to run independently. + import process from 'node:process' import { hideBin } from 'yargs/helpers' @@ -27,6 +30,7 @@ const parseArgs = (argv: string[]) => { alias: 'q', type: 'string', description: 'The named queue to work on', + default: null, }) .option('workoff', { alias: 'o', @@ -43,7 +47,13 @@ const parseArgs = (argv: string[]) => { .help().argv } -const setProcessTitle = ({ id, queue }: { id: string; queue: string }) => { +const setProcessTitle = ({ + id, + queue, +}: { + id: number + queue: string | null +}) => { // set the process title let title = TITLE_PREFIX if (queue) { @@ -85,9 +95,6 @@ const setupSignals = ({ const main = async () => { const { id, queue, clear, workoff } = await parseArgs(process.argv) - // TODO Rob: I'll let you decide how you want to handle the type errors here - // @ts-expect-error - id is a number, and queue can be undefined - // setProcessTitle wants two strings setProcessTitle({ id, queue }) const logger = await loadLogger() From 5a521b026f3d9b927e08cad10a1acc1dcf3c078f Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 11 Jul 2024 13:22:40 -0700 Subject: [PATCH 079/258] Remove TODO --- packages/jobs/src/core/RedwoodJob.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/jobs/src/core/RedwoodJob.ts b/packages/jobs/src/core/RedwoodJob.ts index 3a65f2345c91..747d45ffb8d2 100644 --- a/packages/jobs/src/core/RedwoodJob.ts +++ b/packages/jobs/src/core/RedwoodJob.ts @@ -135,12 +135,7 @@ export abstract class RedwoodJob { } // Must be implemented by the subclass - // TODO TOBBE here's that argument that stopped TS complaining, but you mentioned a generic? The user defines this method in their own subclass, so they can do whatever they want with arguments/types in their own job. The performNow() and performLater() functions above just forward whatever arguments they received to this function - // TODO Rob: What's the correct return type here? Do you expect the perform() method to return something? abstract perform(..._args: any[]): any - // { - // throw new PerformNotImplementedError() - // } // Returns data sent to the adapter for scheduling payload(args: any[]) { From 605ade5634c3f96179606e58d697f3eceaaab2d5 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 11 Jul 2024 13:22:52 -0700 Subject: [PATCH 080/258] Allow `queue` to be null --- packages/jobs/src/core/Worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jobs/src/core/Worker.ts b/packages/jobs/src/core/Worker.ts index 0d3a297e6d49..04a37119d37a 100644 --- a/packages/jobs/src/core/Worker.ts +++ b/packages/jobs/src/core/Worker.ts @@ -15,7 +15,7 @@ interface WorkerOptions { logger?: BasicLogger clear?: boolean processName?: string - queue?: string + queue?: string | null maxRuntime?: number waitTime?: number forever?: boolean From df241b3010e7613e98ee89a4d9594122f5bc4041 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 12 Jul 2024 10:22:18 -0700 Subject: [PATCH 081/258] Adds `yarn rw g job` generator --- .../__tests__/__snapshots__/job.test.ts.snap | 39 ++++ .../generate/job/__tests__/job.test.ts | 83 ++++++++ packages/cli/src/commands/generate/job/job.js | 182 ++++++++++++++++++ .../generate/job/templates/job.ts.template | 7 + .../job/templates/scenarios.ts.template | 8 + .../generate/job/templates/test.ts.template | 9 + packages/cli/src/lib/test.js | 1 + 7 files changed, 329 insertions(+) create mode 100644 packages/cli/src/commands/generate/job/__tests__/__snapshots__/job.test.ts.snap create mode 100644 packages/cli/src/commands/generate/job/__tests__/job.test.ts create mode 100644 packages/cli/src/commands/generate/job/job.js create mode 100644 packages/cli/src/commands/generate/job/templates/job.ts.template create mode 100644 packages/cli/src/commands/generate/job/templates/scenarios.ts.template create mode 100644 packages/cli/src/commands/generate/job/templates/test.ts.template diff --git a/packages/cli/src/commands/generate/job/__tests__/__snapshots__/job.test.ts.snap b/packages/cli/src/commands/generate/job/__tests__/__snapshots__/job.test.ts.snap new file mode 100644 index 000000000000..611dfa98f630 --- /dev/null +++ b/packages/cli/src/commands/generate/job/__tests__/__snapshots__/job.test.ts.snap @@ -0,0 +1,39 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Single word default files > creates a single word function file > Scenario snapshot 1`] = ` +"import type { ScenarioData } from '@redwoodjs/testing/api' + +export const standard = defineScenario({ + // Define the "fixture" to write into your test database here + // See guide: https://redwoodjs.com/docs/testing#scenarios +}) + +export type StandardScenario = ScenarioData +" +`; + +exports[`Single word default files > creates a single word function file > Test snapshot 1`] = ` +"import { SampleJob } from './SampleJob' + +describe('Sample', () => { + it('should not throw any errors', async () => { + const job = new SampleJob() + + await expect(job.perform()).resolves.not.toThrow() + }) +}) +" +`; + +exports[`Single word default files > creates a single word function file 1`] = ` +"import { RedwoodJob } from '@redwoodjs/jobs' + +export class SampleJob extends RedwoodJob { + async perform() { + // job implementation here + } +} +" +`; + +exports[`multi-word files > creates a multi word function file 1`] = `undefined`; diff --git a/packages/cli/src/commands/generate/job/__tests__/job.test.ts b/packages/cli/src/commands/generate/job/__tests__/job.test.ts new file mode 100644 index 000000000000..b2600ea886a3 --- /dev/null +++ b/packages/cli/src/commands/generate/job/__tests__/job.test.ts @@ -0,0 +1,83 @@ +globalThis.__dirname = __dirname +// Load shared mocks +import '../../../../lib/test' + +import path from 'path' + +import { describe, it, expect } from 'vitest' + +import * as jobGenerator from '../job' + +// Should be refactored as it's repeated +type WordFilesType = { [key: string]: string } + +describe('Single word default files', async () => { + const files: WordFilesType = await jobGenerator.files({ + name: 'Sample', + tests: true, + typescript: true, + }) + + it('creates a single word function file', () => { + expect( + files[ + path.normalize('/path/to/project/api/src/jobs/SampleJob/SampleJob.ts') + ], + ).toMatchSnapshot() + + expect( + files[ + path.normalize( + '/path/to/project/api/src/jobs/SampleJob/SampleJob.test.ts', + ) + ], + ).toMatchSnapshot('Test snapshot') + + expect( + files[ + path.normalize( + '/path/to/project/api/src/jobs/SampleJob/SampleJob.scenarios.ts', + ) + ], + ).toMatchSnapshot('Scenario snapshot') + }) +}) + +describe('multi-word files', () => { + it('creates a multi word function file', async () => { + const multiWordDefaultFiles = await jobGenerator.files({ + name: 'send-mail', + tests: false, + typescript: true, + }) + + expect( + multiWordDefaultFiles[ + path.normalize( + '/path/to/project/api/src/functions/SendMailJob/SendMailJob.js', + ) + ], + ).toMatchSnapshot() + }) +}) + +describe('generation of js files', async () => { + const jsFiles: WordFilesType = await jobGenerator.files({ + name: 'Sample', + tests: true, + typescript: false, + }) + + it('returns tests, scenario and job file for JS', () => { + const fileNames = Object.keys(jsFiles) + expect(fileNames.length).toEqual(3) + + expect(fileNames).toEqual( + expect.arrayContaining([ + expect.stringContaining('SampleJob.js'), + expect.stringContaining('SampleJob.test.js'), + expect.stringContaining('SampleJob.scenarios.js'), + ]), + ) + }) +}) diff --git a/packages/cli/src/commands/generate/job/job.js b/packages/cli/src/commands/generate/job/job.js new file mode 100644 index 000000000000..765d831d0714 --- /dev/null +++ b/packages/cli/src/commands/generate/job/job.js @@ -0,0 +1,182 @@ +import path from 'path' + +import * as changeCase from 'change-case' +import { Listr } from 'listr2' +import terminalLink from 'terminal-link' + +import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' +import { errorTelemetry } from '@redwoodjs/telemetry' + +import { getPaths, transformTSToJS, writeFilesTask } from '../../../lib' +import c from '../../../lib/colors' +import { isTypeScriptProject } from '../../../lib/project' +import { prepareForRollback } from '../../../lib/rollback' +import { yargsDefaults } from '../helpers' +import { validateName, templateForComponentFile } from '../helpers' + +// Makes sure the name ends up looking like: `WelcomeNotice` even if the user +// called it `welcome-notice` or `welcomeNoticeJob` or anything else +const normalizeName = (name) => { + return changeCase.pascalCase(name).replace(/Job$/, '') +} + +export const files = async ({ + name, + typescript: generateTypescript, + tests: generateTests = true, + ...rest +}) => { + const extension = generateTypescript ? '.ts' : '.js' + + const outputFiles = [] + + const jobName = normalizeName(name) + + const jobFiles = await templateForComponentFile({ + name: jobName, + componentName: jobName, + extension, + apiPathSection: 'jobs', + generator: 'job', + templatePath: 'job.ts.template', + templateVars: { name: jobName, ...rest }, + outputPath: path.join( + getPaths().api.jobs, + `${jobName}Job`, + `${jobName}Job${extension}`, + ), + }) + + outputFiles.push(jobFiles) + + if (generateTests) { + const testFile = await templateForComponentFile({ + name: jobName, + componentName: jobName, + extension, + apiPathSection: 'jobs', + generator: 'job', + templatePath: 'test.ts.template', + templateVars: { ...rest }, + outputPath: path.join( + getPaths().api.jobs, + `${jobName}Job`, + `${jobName}Job.test${extension}`, + ), + }) + + const scenarioFile = await templateForComponentFile({ + name: jobName, + componentName: jobName, + extension, + apiPathSection: 'jobs', + generator: 'job', + templatePath: 'scenarios.ts.template', + templateVars: { ...rest }, + outputPath: path.join( + getPaths().api.jobs, + `${jobName}Job`, + `${jobName}Job.scenarios${extension}`, + ), + }) + + outputFiles.push(testFile) + outputFiles.push(scenarioFile) + } + + return outputFiles.reduce(async (accP, [outputPath, content]) => { + const acc = await accP + + const template = generateTypescript + ? content + : await transformTSToJS(outputPath, content) + + return { + [outputPath]: template, + ...acc, + } + }, Promise.resolve({})) +} + +export const command = 'job ' +export const description = 'Generate a Background Job' + +// This could be built using createYargsForComponentGeneration; +// however, functions shouldn't have a `stories` option. createYargs... +// should be reversed to provide `yargsDefaults` as the default configuration +// and accept a configuration such as its CURRENT default to append onto a command. +export const builder = (yargs) => { + yargs + .positional('name', { + description: 'Name of the Job', + type: 'string', + }) + .option('typescript', { + alias: 'ts', + description: 'Generate TypeScript files', + type: 'boolean', + default: isTypeScriptProject(), + }) + .option('tests', { + description: 'Generate test files', + type: 'boolean', + default: true, + }) + .option('rollback', { + description: 'Revert all generator actions if an error occurs', + type: 'boolean', + default: true, + }) + .epilogue( + `Also see the ${terminalLink( + 'Redwood CLI Reference', + 'https://redwoodjs.com/docs/cli-commands#generate-job', + )}`, + ) + + // Add default options, includes '--typescript', '--javascript', '--force', ... + Object.entries(yargsDefaults).forEach(([option, config]) => { + yargs.option(option, config) + }) +} + +// This could be built using createYargsForComponentGeneration; +// however, we need to add a message after generating the function files +export const handler = async ({ name, force, ...rest }) => { + recordTelemetryAttributes({ + command: 'generate job', + force, + rollback: rest.rollback, + }) + + validateName(name) + + const tasks = new Listr( + [ + { + title: 'Generating job files...', + task: async () => { + return writeFilesTask(await files({ name, ...rest }), { + overwriteExisting: force, + }) + }, + }, + { + title: 'Adding to api/src/lib/jobs export...', + task: async () => {}, + }, + ], + { rendererOptions: { collapseSubtasks: false }, exitOnError: true }, + ) + + try { + if (rest.rollback && !force) { + prepareForRollback(tasks) + } + await tasks.run() + } catch (e) { + errorTelemetry(process.argv, e.message) + console.error(c.error(e.message)) + process.exit(e?.exitCode || 1) + } +} diff --git a/packages/cli/src/commands/generate/job/templates/job.ts.template b/packages/cli/src/commands/generate/job/templates/job.ts.template new file mode 100644 index 000000000000..047750694324 --- /dev/null +++ b/packages/cli/src/commands/generate/job/templates/job.ts.template @@ -0,0 +1,7 @@ +import { RedwoodJob } from '@redwoodjs/jobs' + +export class ${name}Job extends RedwoodJob { + async perform() { + // job implementation here + } +} diff --git a/packages/cli/src/commands/generate/job/templates/scenarios.ts.template b/packages/cli/src/commands/generate/job/templates/scenarios.ts.template new file mode 100644 index 000000000000..d24ff747f6bb --- /dev/null +++ b/packages/cli/src/commands/generate/job/templates/scenarios.ts.template @@ -0,0 +1,8 @@ +import type { ScenarioData } from '@redwoodjs/testing/api' + +export const standard = defineScenario({ + // Define the "fixture" to write into your test database here + // See guide: https://redwoodjs.com/docs/testing#scenarios +}) + +export type StandardScenario = ScenarioData diff --git a/packages/cli/src/commands/generate/job/templates/test.ts.template b/packages/cli/src/commands/generate/job/templates/test.ts.template new file mode 100644 index 000000000000..7dc44353b3a3 --- /dev/null +++ b/packages/cli/src/commands/generate/job/templates/test.ts.template @@ -0,0 +1,9 @@ +import { ${name}Job } from './${name}Job' + +describe('${name}', () => { + it('should not throw any errors', async () => { + const job = new ${name}Job() + + await expect(job.perform()).resolves.not.toThrow() + }) +}) diff --git a/packages/cli/src/lib/test.js b/packages/cli/src/lib/test.js index ae6bbc792428..2062817682ab 100644 --- a/packages/cli/src/lib/test.js +++ b/packages/cli/src/lib/test.js @@ -42,6 +42,7 @@ vi.mock('@redwoodjs/project-config', async (importOriginal) => { ), // this folder generators: path.join(BASE_PATH, './api/generators'), src: path.join(BASE_PATH, './api/src'), + jobs: path.join(BASE_PATH, './api/src/jobs'), services: path.join(BASE_PATH, './api/src/services'), directives: path.join(BASE_PATH, './api/src/directives'), graphql: path.join(BASE_PATH, './api/src/graphql'), From 714189b074147af73d32e02ec59a52d7232f388a Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 12 Jul 2024 13:04:33 -0700 Subject: [PATCH 082/258] Switch to `enable` to skip model creation steps altogether, cleanup output message --- .../src/commands/setup/jobs/jobsHandler.js | 55 +++++++++---------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/commands/setup/jobs/jobsHandler.js b/packages/cli/src/commands/setup/jobs/jobsHandler.js index b66f77c7424f..74ceca0f4fc9 100644 --- a/packages/cli/src/commands/setup/jobs/jobsHandler.js +++ b/packages/cli/src/commands/setup/jobs/jobsHandler.js @@ -41,39 +41,32 @@ const addModel = () => { fs.writeFileSync(getPaths().api.dbSchema, schemaWithUser) } -const tasks = ({ force }) => { - let skipSchema = false +const tasks = async ({ force }) => { + const modelExists = (await getModelNames()).includes('BackgroundJob') return new Listr( [ { title: 'Creating job model...', - task: async (_ctx, task) => { - if ((await getModelNames()).includes('BackgroundJob')) { - skipSchema = true - task.skip('Model already exists, skipping creation') - } else { - addModel() - } + task: () => { + addModel() }, + enabled: () => !modelExists, }, { title: 'Migrating database...', - task: async (_ctx, task) => { - if (skipSchema) { - task.skip('Model already exists, skipping migration') - } else { - execa.sync( - 'yarn rw prisma migrate dev', - ['--name', 'create-background-jobs'], - { - shell: true, - cwd: getPaths().base, - stdio: 'inherit', - }, - ) - } + task: () => { + execa.sync( + 'yarn rw prisma migrate dev', + ['--name', 'create-background-jobs'], + { + shell: true, + cwd: getPaths().base, + stdio: 'inherit', + }, + ) }, + enabled: () => !modelExists, }, { title: 'Creating config file in api/src/lib...', @@ -119,11 +112,15 @@ const tasks = ({ force }) => { title: 'One more thing...', task: (_ctx, task) => { task.title = `One more thing... - ${c.green('Background jobs configured!\n')} - ${'Generate jobs with:'} ${c.warning('yarn rw g job ')} - ${'Execute jobs with:'} ${c.warning('yarn rw jobs work\n')} - ${'Check out the docs for more info:'} - ${chalk.hex('#e8e8e8')('https://docs.redwoodjs.com/docs/background-jobs')} + + ${c.success('\nBackground jobs configured!\n')} + + Generate jobs with: ${c.warning('yarn rw g job ')} + Execute jobs with: ${c.warning('yarn rw jobs work\n')} + + Check out the docs for more info: + ${c.link('https://docs.redwoodjs.com/docs/background-jobs')}\n + ` }, }, @@ -133,7 +130,7 @@ const tasks = ({ force }) => { } export const handler = async ({ force }) => { - const t = tasks({ force }) + const t = await tasks({ force }) try { await t.run() From 8dc207cc6e971be37f60a7f7f81e59b53eef195e Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 12 Jul 2024 13:04:33 -0700 Subject: [PATCH 083/258] Switch to `enable` to skip model creation steps altogether, cleanup output message --- packages/cli/src/commands/setup/jobs/jobsHandler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/setup/jobs/jobsHandler.js b/packages/cli/src/commands/setup/jobs/jobsHandler.js index 74ceca0f4fc9..b0f3f29f7c11 100644 --- a/packages/cli/src/commands/setup/jobs/jobsHandler.js +++ b/packages/cli/src/commands/setup/jobs/jobsHandler.js @@ -119,7 +119,7 @@ const tasks = async ({ force }) => { Execute jobs with: ${c.warning('yarn rw jobs work\n')} Check out the docs for more info: - ${c.link('https://docs.redwoodjs.com/docs/background-jobs')}\n + ${c.link('https://docs.redwoodjs.com/docs/background-jobs')} ` }, From 5dd7b8ea6cfbe8f60a8fdb136a6f87815af7a1f7 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 12 Jul 2024 15:44:03 -0700 Subject: [PATCH 084/258] Start of job docs --- docs/docs/background-jobs.md | 241 ++++++++++++++++++ docs/sidebars.js | 1 + .../static/img/background-jobs/jobs-after.png | Bin 0 -> 82168 bytes .../img/background-jobs/jobs-before.png | Bin 0 -> 59872 bytes .../img/background-jobs/jobs-terminal.png | Bin 0 -> 209007 bytes 5 files changed, 242 insertions(+) create mode 100644 docs/docs/background-jobs.md create mode 100644 docs/static/img/background-jobs/jobs-after.png create mode 100644 docs/static/img/background-jobs/jobs-before.png create mode 100644 docs/static/img/background-jobs/jobs-terminal.png diff --git a/docs/docs/background-jobs.md b/docs/docs/background-jobs.md new file mode 100644 index 000000000000..9b4f6f851bb6 --- /dev/null +++ b/docs/docs/background-jobs.md @@ -0,0 +1,241 @@ +# Background Jobs + +No one likes waiting in line. This is especially true of your website: users don't want to wait for things to load that don't directly impact the task they're trying to accomplish. For example, sending a "welcome" email when a new user signs up. The process of sending the email could as long or longer than the sum total of everything else that happens during that request. Why make the user wait for it? As long as they eventually get the email, everything is good. + +A typical create-user flow could look something like this: + +![image](/img/background-jobs/jobs-before.png) + +If we want the email to be send asynchonously, we can shuttle that process off into a **background job**: + +![image](/img/background-jobs/jobs-after.png) + +The user's response is returned much quicker, and the email is sent by another process which is connected to a user's session. All of the logic around sending the email is packaged up as a **job** and the **job server** is responsible for executing it. + +The job is completely self-contained and has everything it needs to perform its task. Let's see how Redwood implements this workflow. + +## Overview + +### Workflow + +There are three components to the background job system in Redwood: + +1. Scheduling +2. Storage +3. Execution + +**Scheduling** is the main interface to background jobs from within your application code. This is where you tell the system to run a job at some point in the future, whether that's "as soon as possible" or to delay for an amount of time first, or to run at a specific datetime in the future. Scheduling is handled by calling `performLater()` on an instance of your job. + +**Storage** is necessary so that your jobs are decoupled from your application. By default jobs are stored in your database. This allows you to scale everything independently: the api server (which is scheduling jobs), the database (which is storing the jobs ready to be run), and the jobs runner (which is executing the jobs). + +**Execution** is handled by the job runner, which takes a job from storage, executes it, and then does something with the result, whether it was a success or failure. + +### Installation + +To get started with jobs, run the setup command: + +```bash +yarn rw setup jobs +``` + +This will add a new model to your Prisma schema, migrate the database, and create a configuration file at `api/src/lib/jobs.js` (or `.ts` for a Typescript project). Comments have been removed for brevity: + +```js +import { PrismaAdapter, RedwoodJob } from '@redwoodjs/jobs' +import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' + +export const adapter = new PrismaAdapter({ db, logger }) + +RedwoodJob.config({ adapter, logger }) + +export const jobs = {} +``` + +Note the `PrismaAdapter` which is what enables storing jobs in the database. Calling `RedwoodJob.config()` sets the adapter and logger as the default for all other jobs in the system, but can be overridden on a per-job basis. + +We'll go into more detail on this file later, but what's there now is fine to get started creating a job. + +### Creating a Job + +Jobs are defined as a subclass of the `RedwoodJob` class and at a minimum contains a function named `perform()` which contains the logic for your job. You can add as many additional functions you want to support the task your job is performing, but `perform()` is what's invoked by the **job runner** that we'll see later. The actual files for jobs live in `api/src/jobs`. + +An example `SendWelcomeEmailJob` may look something like: + +```js +import { RedwoodJob } from '@redwoodjs/jobs' +import { mailer } from 'src/lib/mailer' +import { WelcomeEmail } from 'src/mail/WelcomeEmail' + +export class SendWelcomeEmailJob extends RedwoodJob { + + perform(userId) { + const user = await db.user.findUnique({ where: { id: userId } }) + await mailer.send(WelcomeEmail({ user }), { + to: user.email, + subject: `Welcome to the site!`, + }) + } + +} +``` + +Note that `perform()` can take any arguments you want, but it's a best practice to keep them as simple as possible: a reference to this job and its arguments are stored in the database, so the list of arguments must be serializable to and from a string of JSON. Most jobs will probably act against data in your database, so it makes send to have the arguments simply be the `id` of those database records. When the job executes it will look up the full database record and then proceed from there. + +As you can see, the code inside is identical to what you'd do if you were going to send an email directly from the `createUser` service. But now the user won't be waiting for `mailer.send()` to do its thing, it will happen behind the scenes. + +There are a couple different ways to invoke a job, but the simplest is to include an instance of your new job in the `jobs` object that's exported at the end of `api/src/lib/jobs.js`: + +```js +import { PrismaAdapter, RedwoodJob } from '@redwoodjs/jobs' +import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' +// highlight-next-line +import { SendWelcomeEmailJob } from 'src/jobs/SendWelcomeEmailJob' + +export const adapter = new PrismaAdapter({ db, logger }) + +RedwoodJob.config({ adapter, logger }) + +export jobs = { + // highlight-next-line + sendWelcomeEmailJob: new SendWelcomeEmailJob() +} +``` + +This makes it easy to import and schedule your job as we'll see next. + +### Scheduling a Job + +All jobs expose a `performLater()` function (inherited from the parent `RedwoodJob` class). Simply call this function when you want to schedule your job. Carrying on with our example from above, let's schedule this job as part of the `createUser()` service that used to be sending the email directly: + +```js +// highlight-next-line +import { jobs } from 'api/src/lib/jobs' + +export const createUser({ input }) { + const user = await db.user.create({ data: input }) + // highlight-next-line + await jobs.sendWelcomeEmailJob.performLater(user.id) + return user +} +``` + +If we were to query the `BackgroundJob` table now we'd see a new row: + +```json +{ + id: 1, + attempts: 0, + handler: '{"handler":"SampleJob","args":[335]}', + queue: 'default', + priority: 50, + runAt: 2024-07-12T22:27:51.085Z, + lockedAt: null, + lockedBy: null, + lastError: null, + failedAt: null, + createdAt: 2024-07-12T22:27:51.125Z, + updatedAt: 2024-07-12T22:27:51.125Z +} +``` + +:::info + +Because we're using the `PrismaAdapter` here all jobs are stored in the database, but if you were using a different storage mechanism via a different adapter you would have to query those in a manner specific to that adapter's storage mechanism. + +::: + +That's it! Your application code can go about its business knowing that eventually that job will execute and the email will go out. Finally, let's look at how a job is run. + +### Running a Job + +In development you can start the job runner from the command line: + +```bash +yarn rw jobs work +``` + +The runner is a sort of overseer that doesn't do any work itself, but spawns workers to actually execute the jobs. When starting in `work` mode a single worker will spin up and stay attached to the terminal and update you on the status of what it's doing: + +![image](/img/background-jobs/jobs-terminal.png) + +It checks the `BackgroundJob` table every few seconds for a new job and, if it finds one, locks it so that no other workers can have it, then calls `perform()` passing the arguments you gave to `performLater()`. + +If the job succeeds then it's removed the database. If the job fails, the job is un-locked in the database, the `runAt` is set to an incremental backoff time in the future, and `lastError` is updated with the error that occurred. The job will now be picked up in the future once the `runAt` time has passed and it'll try again. + +## Detailed Usage + +### Global Job Configuration + +### Per-job Configuration + +### Job Scheduling + +### Job Runner + +#### Dev Modes + +To run your jobs, start up the runner: + +```bash +yarn rw jobs work +``` + +This process will stay attached the console and continually look for new jobs and execute them as they are found. To work on whatever outstanding jobs there are and then exit, use the `workoff` mode instead: + +```bash +yarn rw jobs workoff +``` + +As soon as there are no more jobs to be executed (either the table is empty, or they are scheduled in the future) the process will automatically exit. + +#### Clear + +You can remove all jobs from storage with: + +```bash +yarn rw jobs clear +``` + +#### Production Modes + +To run the worker(s) in the background, use the `start` mode: + +```bash +yarn rw jobs start +``` + +To stop them: + +```bash +yarn rw jobs stop +``` + +You can start more than one worker by passing the `-n` flag: + +```bash +yarn rw jobs start -n 4 +``` + +If you want to specify that some workers only work on certain named queues: + +```bash +yarn rw jobs start -n default:2,email:1 +``` + +Make sure you pass the same flags to the `stop` process as the `start` so it knows which ones to stop. You can `restart` your workers as well. + +In production you'll want to hook the workers up to a process monitor as, just like with any other process, they could die unexpectedly. More on this in the docs. + +## Creating Your Own Adapter + +## The Future + +There's still more to add to background jobs! Our current TODO list: + +* More adapters: Redis, SQS, RabbitMQ... +* RW Studio integration: monitor the state of your outstanding jobs +* Baremetal integration: if jobs are enabled, monitor the workers with pm2 +* Recurring jobs +* Livecycle hooks: `beforePerform()`, `afterPerform()`, `afterSuccess()`, `afterFailure()` diff --git a/docs/sidebars.js b/docs/sidebars.js index e5041318e477..904eec861c32 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -97,6 +97,7 @@ module.exports = { { type: 'doc', id: 'auth/supertokens' }, ], }, + 'background-jobs', 'builds', 'cells', 'cli-commands', diff --git a/docs/static/img/background-jobs/jobs-after.png b/docs/static/img/background-jobs/jobs-after.png new file mode 100644 index 0000000000000000000000000000000000000000..de1d540e49c397f53617317e87ef2299d64a7e76 GIT binary patch literal 82168 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

ecw80~OjLskx^0+1PQxt)=ete7(M6i3P0Eudc; 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 literal 0 HcmV?d00001 diff --git a/docs/static/img/background-jobs/jobs-before.png b/docs/static/img/background-jobs/jobs-before.png new file mode 100644 index 0000000000000000000000000000000000000000..a5ad12f905f7fd0fde4d767a3d5aef1b6c36bf1c GIT binary patch literal 59872 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%0A9TQYkUeR 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∋z;+(mG-0{#u*2i!zfUDisc< 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 z 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 KtU|#u Date: Fri, 12 Jul 2024 18:13:27 -0700 Subject: [PATCH 085/258] Add new job to lib/jobs config --- packages/cli/src/commands/generate/job/job.js | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/generate/job/job.js b/packages/cli/src/commands/generate/job/job.js index 765d831d0714..227ebc2fb741 100644 --- a/packages/cli/src/commands/generate/job/job.js +++ b/packages/cli/src/commands/generate/job/job.js @@ -1,4 +1,5 @@ -import path from 'path' +import fs from 'node:fs' +import path from 'node:path' import * as changeCase from 'change-case' import { Listr } from 'listr2' @@ -151,6 +152,9 @@ export const handler = async ({ name, force, ...rest }) => { validateName(name) + const jobName = normalizeName(name) + const newJobExport = `${changeCase.camelCase(jobName)}Job: new ${jobName}Job()` + const tasks = new Listr( [ { @@ -163,7 +167,27 @@ export const handler = async ({ name, force, ...rest }) => { }, { title: 'Adding to api/src/lib/jobs export...', - task: async () => {}, + task: async () => { + const file = fs.readFileSync(getPaths().api.jobsConfig).toString() + const newFile = file + .replace( + /export const jobs = \{/, + `export const jobs = {\n ${newJobExport},`, + ) + .replace( + /(import \{ db \} from 'src\/lib\/db')/, + `import ${jobName}Job from 'src/jobs/${jobName}Job'\n$1`, + ) + fs.writeFileSync(getPaths().api.jobsConfig, newFile) + }, + skip: () => { + const file = fs.readFileSync(getPaths().api.jobsConfig).toString() + if (!file || !file.match(/export const jobs = \{/)) { + return '`jobs` export not found, skipping' + } else if (file.match(newJobExport)) { + return `${jobName}Job already exported, skipping` + } + }, }, ], { rendererOptions: { collapseSubtasks: false }, exitOnError: true }, From 5fb418034216fb9cfee73d977e98d70046d2a89d Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 12 Jul 2024 18:13:45 -0700 Subject: [PATCH 086/258] Remove accessor from PrismaAdapterOptions --- packages/jobs/src/adapters/PrismaAdapter.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/jobs/src/adapters/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter.ts index 909f9c765511..c93698a82614 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter.ts @@ -56,7 +56,6 @@ interface PrismaJob extends BaseJob { interface PrismaAdapterOptions extends BaseAdapterOptions { db: PrismaClient model?: string - accessor?: keyof PrismaClient maxAttempts?: number } From eaa60916f026a153a2d04145daf3cd40acc77d73 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 12 Jul 2024 18:14:11 -0700 Subject: [PATCH 087/258] Add a couple TODOs --- packages/jobs/src/adapters/BaseAdapter.ts | 1 + packages/jobs/src/core/Executor.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/jobs/src/adapters/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter.ts index 3f0a4b170431..1812b70f6b46 100644 --- a/packages/jobs/src/adapters/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter.ts @@ -56,6 +56,7 @@ export abstract class BaseAdapter< abstract find(args: FindArgs): BaseJob | null | Promise + // TODO accept an optional `queue` arg to clear only jobs in that queue abstract clear(): any abstract success(job: BaseJob): any diff --git a/packages/jobs/src/core/Executor.ts b/packages/jobs/src/core/Executor.ts index d88ee2d0aea3..137ab517c680 100644 --- a/packages/jobs/src/core/Executor.ts +++ b/packages/jobs/src/core/Executor.ts @@ -43,6 +43,7 @@ export class Executor { this.logger.info(this.job, `Started job ${this.job.id}`) const details = JSON.parse(this.job.handler) + // TODO try to use the adapter defined by the job itself, otherwise fallback to this.adapter try { const jobModule = await loadJob(details.handler) await new jobModule[details.handler]().perform(...details.args) From 8bd6bc76372dd8304f43d28be7c362878b03c48b Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 12 Jul 2024 18:14:25 -0700 Subject: [PATCH 088/258] Move default priority to a constant --- packages/jobs/src/core/RedwoodJob.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/jobs/src/core/RedwoodJob.ts b/packages/jobs/src/core/RedwoodJob.ts index 747d45ffb8d2..39e4ca88a4c2 100644 --- a/packages/jobs/src/core/RedwoodJob.ts +++ b/packages/jobs/src/core/RedwoodJob.ts @@ -39,15 +39,17 @@ export interface JobSetOptions { export const DEFAULT_QUEUE = 'default' +export const DEFAULT_PRIORITY = 50 + export abstract class RedwoodJob { // The default queue for all jobs static queue = DEFAULT_QUEUE // The default priority for all jobs - // Assumes a range of 1 - 100, 1 being highest priority - static priority = 50 + // Lower numbers are higher priority (1 is higher priority than 100) + static priority = DEFAULT_PRIORITY - // The adapter to use for scheduling jobs. Set via the static `config` method + // The adapter to use for scheduling jobs static adapter: BaseAdapter // Set via the static `config` method From 5561e82b9620b5a875534b0142644a369c9e094f Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 12 Jul 2024 18:14:30 -0700 Subject: [PATCH 089/258] Start of job docs --- docs/docs/background-jobs.md | 349 +++++++++++++++++++++++++++++++---- 1 file changed, 313 insertions(+), 36 deletions(-) diff --git a/docs/docs/background-jobs.md b/docs/docs/background-jobs.md index 9b4f6f851bb6..d81a27c9b415 100644 --- a/docs/docs/background-jobs.md +++ b/docs/docs/background-jobs.md @@ -14,7 +14,7 @@ The user's response is returned much quicker, and the email is sent by another p The job is completely self-contained and has everything it needs to perform its task. Let's see how Redwood implements this workflow. -## Overview +## Overview & Quick Start ### Workflow @@ -52,13 +52,17 @@ RedwoodJob.config({ adapter, logger }) export const jobs = {} ``` -Note the `PrismaAdapter` which is what enables storing jobs in the database. Calling `RedwoodJob.config()` sets the adapter and logger as the default for all other jobs in the system, but can be overridden on a per-job basis. - -We'll go into more detail on this file later, but what's there now is fine to get started creating a job. +We'll go into more detail on this file later (see [RedwoodJob (Global) Configuration](#redwoodjob-global-configuration)), but what's there now is fine to get started creating a job. ### Creating a Job -Jobs are defined as a subclass of the `RedwoodJob` class and at a minimum contains a function named `perform()` which contains the logic for your job. You can add as many additional functions you want to support the task your job is performing, but `perform()` is what's invoked by the **job runner** that we'll see later. The actual files for jobs live in `api/src/jobs`. +We have a generator that creates a job in `api/src/jobs`: + +```bash +yarn rw g job SendWelcomeEmail +``` + +Jobs are defined as a subclass of the `RedwoodJob` class and at a minimum must contain the function named `perform()` which contains the logic for your job. You can add as many additional functions you want to support the task your job is performing, but `perform()` is what's invoked by the **job runner** that we'll see later. An example `SendWelcomeEmailJob` may look something like: @@ -80,11 +84,9 @@ export class SendWelcomeEmailJob extends RedwoodJob { } ``` -Note that `perform()` can take any arguments you want, but it's a best practice to keep them as simple as possible: a reference to this job and its arguments are stored in the database, so the list of arguments must be serializable to and from a string of JSON. Most jobs will probably act against data in your database, so it makes send to have the arguments simply be the `id` of those database records. When the job executes it will look up the full database record and then proceed from there. +Note that `perform()` can take any arguments you want, but it's a best practice to keep them as simple as possible: a reference to this job and its arguments are stored in the database, so the list of arguments must be serializable to and from a string of JSON. Most jobs will probably act against data in your database, so it makes sense to have the arguments simply be the `id` of those database records. When the job executes it will look up the full database record and then proceed from there. -As you can see, the code inside is identical to what you'd do if you were going to send an email directly from the `createUser` service. But now the user won't be waiting for `mailer.send()` to do its thing, it will happen behind the scenes. - -There are a couple different ways to invoke a job, but the simplest is to include an instance of your new job in the `jobs` object that's exported at the end of `api/src/lib/jobs.js`: +There are a couple different ways to invoke a job, but the simplest is to include an instance of your new job in the `jobs` object that's exported at the end of `api/src/lib/jobs.js` (note that the job generator will do this for you): ```js import { PrismaAdapter, RedwoodJob } from '@redwoodjs/jobs' @@ -121,13 +123,19 @@ export const createUser({ input }) { } ``` -If we were to query the `BackgroundJob` table now we'd see a new row: +Or if you wanted to wait 5 minutes before sending the email you can set a `wait` time (number of seconds): + +```js +await jobs.sendWelcomeEmailJob.set({ wait: 300 }).performLater(user.id) +``` + +If we were to query the `BackgroundJob` table after the job has been scheduled you'd see a new row: ```json { id: 1, attempts: 0, - handler: '{"handler":"SampleJob","args":[335]}', + handler: '{"handler":"SendWelcomeEmailJob","args":[335]}', queue: 'default', priority: 50, runAt: 2024-07-12T22:27:51.085Z, @@ -140,15 +148,15 @@ If we were to query the `BackgroundJob` table now we'd see a new row: } ``` +The `handler` field contains the name of the job class and the arguments its `perform()` function will receive. + :::info -Because we're using the `PrismaAdapter` here all jobs are stored in the database, but if you were using a different storage mechanism via a different adapter you would have to query those in a manner specific to that adapter's storage mechanism. +Because we're using the `PrismaAdapter` here all jobs are stored in the database, but if you were using a different storage mechanism via a different adapter you would have to query those in a manner specific to that adapter's backend. ::: -That's it! Your application code can go about its business knowing that eventually that job will execute and the email will go out. Finally, let's look at how a job is run. - -### Running a Job +### Executing Jobs In development you can start the job runner from the command line: @@ -166,70 +174,339 @@ If the job succeeds then it's removed the database. If the job fails, the job is ## Detailed Usage -### Global Job Configuration +All jobs have some default configuration set for you if don't do anything different: + +* `queue` jobs can be in named queues and have dedicated workers that only pull jobs from that queue. This lets you scale not only your entire job runner independently of the rest of your app, but scale the individual queues as well. By default, all jobs will go in a queue named "default" if you don't override it (see Per-job Configuration below). +* `priority` within a single queue you can jobs that are more or less important. The workers will pull jobs off the queue with a higher priority before working on ones with a lower priority. The default priority is `50`. A lower number is *higher* in priority than a lower number. ie. the workers will work on a job with a priority of `1` before they work on one with a priority of `100`. +* `logger` jobs will log to the console if you don't tell them otherwise. The logger exported from `api/src/lib/logger.js` works well the job runner, so we recommend using that by setting it in `RedwoodJob.config()` or on a per-job basis. +* `adapter`: the adapter to use to store this jobs. There is no default adapter set for jobs, so you'll need to set this in `RedwoodJob.config()` or on a per-job basis. + +## RedwoodJob (Global) Configuration + +Let's take a closer look at `api/src/lib/jobs.js`: + +```js +import { PrismaAdapter, RedwoodJob } from '@redwoodjs/jobs' +import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' + +export const adapter = new PrismaAdapter({ db, logger }) + +RedwoodJob.config({ adapter, logger }) + +export const jobs = {} +``` + +### Exporting an `adapter` + +```js +export const adapter = new PrismaAdapter({ db, logger }) +``` + +This is the adapter that the job runner itself will use to run your jobs if you don't override the adapter in the job itself. In most cases this will be the same for all jobs, but just be aware that you can user different adapters for different jobs if you really want! Exporting an `adapter` in this file is required for the job runner to start. + +### Configuring All Jobs with `RedwoodJob.config` + +```js +RedwoodJob.config({ adapter, logger }) +``` + +This is the global config for all jobs. You can override these values in individual jobs, but if you don't want to this saves you a lot of extra code configuring individual jobs over and over again with the same adapter, logger, etc. -### Per-job Configuration +Jobs will inherit a default queue name of `"default"` and a priority of `50`. + +Config can be given the following options: + +* `adapter`: **[required]** The adapter to use for scheduling your job. +* `logger`: Made available as `this.logger` within your job for logging whatever you'd like during the `perform()` step. This defaults to `console`. + +### Exporting `jobs` + +```js +export const jobs = {} +``` + +We've found a nice convention is to export instances of all of your jobs here. Then you only have a single import to use in other places when you want to schedule a job: + +```js +// api/src/lib/jobs.js +export const jobs = { + sendWelcomeEmailJob: new SendWelcomeEmailJob(), + // highlight-next-line + productBackorderJob: new ProductBackorderJob(), + inventoryReportGenJob: new InventoryReportGenJob(), +} + +// api/src/services/products/products.js +import { jobs } from 'api/src/lib/jobs' + +export const updateProduct = async ({ id, input }) => { + const product = await db.product.update({ where: { id }, data: input }) + // highlight-next-line + await jobs.productBackorderJob.performLater() + return product +} +``` + +It *is* possible to skip this export altogther and import and schedule individual jobs manually: + +```js +// api/src/services/products/products.js +import { ProductBackorderJob } from 'api/src/jobs/ProductBackorderJob' + +export const updateProduct = async ({ id, input }) => { + const product = await db.product.update({ where: { id }, data: input }) + await ProductBackorderJob.performLater() + return product +} +``` + +HOWEVER, this will lead to unexpected behavior if you're not aware of the following: + +:::danger + +If you don't export a `jobs` object and then `import` it when you want to schedule a job, the `Redwood.config()` line will never be executed and your jobs will not receive a default configuration! This means you'll need to either: + +* Invoke `RedwoodJob.config()` somewhere before scheduling your job +* Manually set the adapter/logger/etc. in each of your jobs. + +We'll see examples of configuring the individual jobs with an adapter and logger below. + +::: + +## Per-job Configuration + +If you don't do anything special, a job will inherit the adapter and logger you set with the call to `RedwoodJob.config()`. However, you can override these settings on a per-job basis: + +```js +import { db } from 'api/src/lib/db' +import { emailLogger } from 'api/src/lib/logger' + +export const class SendWelcomeEmailJob extends RedwoodJob { + // highlight-start + static adapter = new PrismaAdapter({ db }) + static logger = emailLogger() + static queue = 'email' + static priority = '1' + // highlight-end + + perform(userId) => { + // ... send email ... + } +} +``` -### Job Scheduling +The variables you can set this way are: -### Job Runner +* `queue`: the named queue that jobs will be put in, defaults to `"default"` +* `priority`: an integer denoting the priority of this job (lower numbers are higher priority). Defaults to `50` +* `logger`: this will be made available as `this.logger` from within your job for internal logging. Defaults to `console` +* `adapter`: this is the adapter that's used when it comes time to schedule and store the job. There is no default, so this must be set either here or everywhere with `RedwoodJob.config({ adapter })` Redwood currently only ships with the `PrismaAdapter` -#### Dev Modes +## Adapter Configuration -To run your jobs, start up the runner: +Adapters accept an object of options when they are initialized. + +### PrismaAdapter + +```js +import { db } from 'api/src/lib/db' + +const adapter = new PrismaAdapter({ + db, + model: 'BackgroundJob', + logger: console, + maxAttemps: 24 +}) +``` + +* `db`: **[required]** an instance of `PrismaClient` that the adapter will use to store, find and update the status of jobs. In most cases this will be the `db` variable exported from `api/src/lib/db.js`. This must be set in order for the adapter to be initialized! +* `model`: the name of the model that was created to store jobs. This defaults to `BackgroundJob` +* `logger`: events that occur within the adapter will be logged using this. This defaults to `console` but the `logger` exported from `api/src/lib/logger` works great, too. +* `maxAttempts` the number of times to allow a job to be retried before giving up. This defaults to `24`. If the `maxAttemps` is reached then the `failedAt` column is set in job's row in the database an no further attempts will be made to run it. + +## Job Scheduling + +The interface to schedule a job is very flexible. We have a recommended way, but there may be another that better suits your specific usecase. + +### Instance Invocation + +Using this pattern you instantiate the job first, set any options, then schedule it: + +```js +import { SendWelcomeEmailJob } from 'api/src/jobs/SendWelcomeEmailJob` + +const job = new SendWelcomeEmailJob() +job.set({ wait: 300 }).performLater() +``` + +You can also do the setting separate from the scheduling: + +```js +const job = new SendWelcomeEmailJob() +job.set({ wait: 300 }) +job.performLater() +``` + +Using this syntax you can also set the queue and priority for only *this instance* of the job, overriding the configuration set on the job itself: + +```js +// Job uses the `default` queue and has a priority of `50` +const job = new SendWelcomeEmailJob() + +job.set({ queue: 'email', priority: 1 }) +// or +job.queue = 'email' +job.priority = 1 + +job.performLater() +``` + +You're using the instance invocation pattern when you add an instance of a job to the `jobs` export of `api/src/lib/jobs.js`: + +```js +// api/src/lib/jobs.js +export const jobs = { + sendWelcomeEmail: new SendWelcomeEmailJob() +} + +// api/src/services/users/users.js +export const createUser = async ({ input }) => { + const user = await db.user.create({ data: input }) + await jobs.sendWelcomeEmail.set({ wait: 300 }).performLater() + return user +} +``` + +### Class Invocation + +You can schedule a job directly, without instantiating it first: + +```js +import { AnnualReportGenerationJob } from 'api/src/jobs/AnnualReportGenerationJob' + +AnnualReportGenerationJob.performLater() +// or +AnnualReportGenerationJob + .set({ waitUntil: new Date(2025, 0, 1) }) + .performLater() +``` + +Using this syntax comes with a caveat: since no `RedwoodJob.config()` was called you would need to configure the adapter directly on `AnnualReportGenerationJob` (unless you were sure that `RedwoodJob.config()` was called somewhere before this code executes). See the note at the end of the [Exporting jobs](#exporting-jobs) section + +### Scheduling Options + +You can pass several options in a `set()` call on your instance or class: + +* `wait`: number of seconds to wait before the job will run +* `waitUntil`: a specific `Date` in the future to run at +* `queue`: the named queue to put this job in (overrides any `static queue` set on the job itself) +* `priority`: the priority to give this job (overrides any `static priority` set on the job itself) + +## Job Runner + +The job runner actually executes your jobs. The runners will ask the adapter to find a job to work on. The adapter will mark the job as locked (the process name and a timestamp is set on the job) and then the worker will instantiate the job class and call `perform()` on it, passing in any args that were given to `performLater()` + +The runner has several modes it can start in depending on how you want it to behave. + +### Dev Modes + +These modes are ideal when you're creating a job and want to be sure it runs correctly while developing. You could also use this in production if you wanted (maybe a job is failing and you want to watch verbose logs and see what's happening). ```bash yarn rw jobs work ``` -This process will stay attached the console and continually look for new jobs and execute them as they are found. To work on whatever outstanding jobs there are and then exit, use the `workoff` mode instead: +This process will stay attached the console and continually look for new jobs and execute them as they are found. Pressing Ctrl-C to cancel the process (sending SIGINT) will start a graceful shutdown: the workers will complete any work they're in the middle of before exiting. To cancel immediately, hit Ctrl-C again (or send SIGTERM) and they'll stop in the middle of what they're doing. Note that this could leave locked jobs in the database, but they will be picked back up again if a new worker starts with the same name as the one that locked the process. + +To work on whatever outstanding jobs there are and then exit, use the `workoff` mode instead: ```bash yarn rw jobs workoff ``` -As soon as there are no more jobs to be executed (either the table is empty, or they are scheduled in the future) the process will automatically exit. +As soon as there are no more jobs to be executed (either the store is empty, or they are scheduled in the future) the process will automatically exit. -#### Clear +By default this worker will work on all queues, but if you only wanted it to work on a specific one even in dev, check out the `-n` flag described in the [Multiple Workers](#multiple-workers) section below. -You can remove all jobs from storage with: +### Production Modes + +In production you'll want your job workers running forever in the background. For that, use the `start` mode: ```bash -yarn rw jobs clear +yarn rw jobs start ``` -#### Production Modes +That will start a single worker, watching all queues, and then detatch it from the console. If you care about the output of that worker then you'll want to have configured a logger that writes to the filesystem or sends to a third party log aggregator. -To run the worker(s) in the background, use the `start` mode: +To stop the worker: ```bash -yarn rw jobs start +yarn rw jobs stop ``` -To stop them: +And to restart: ```bash -yarn rw jobs stop +yarn rw jobs restart ``` -You can start more than one worker by passing the `-n` flag: +### Multiple Workers + +You can start more than one worker with the `-n` flag: ```bash yarn rw jobs start -n 4 ``` -If you want to specify that some workers only work on certain named queues: +That starts 4 workers watching all queues. To only watch a certain queue, you can combine the queue name with the number that should start, separated by a `:`: + +```bash +yarn rw jobs start -n email:2 +``` + +That starts 2 workers that only watch the `email` queue. To have multiple workers watching separate named queues, separate those with commas: + +```bash +yarn rw jobs start -n default:2,email:4 +``` + +2 workers watching the `default` queue and 4 watching `email`. + +If you want to combine named queues and all queues, leave off the name: ```bash -yarn rw jobs start -n default:2,email:1 +yarn rw jobs start -n :2,email:4 ``` -Make sure you pass the same flags to the `stop` process as the `start` so it knows which ones to stop. You can `restart` your workers as well. +2 workers watching all queues and another 4 dedicated to only `email`. + +### Stopping Multiple Workers + +Make sure you pass the same flags to the `stop` process as the `start` so it knows which ones to stop. The same with the `restart` command. + +### Monitoring the Workers -In production you'll want to hook the workers up to a process monitor as, just like with any other process, they could die unexpectedly. More on this in the docs. +In production you'll want to hook the workers up to a process monitor since, just like with any other process, they could die unexpectedly. + +### Clear + +You can remove all jobs from storage with: + +```bash +yarn rw jobs clear +``` ## Creating Your Own Adapter +TODO + +* `find()` should find a job to be run, lock it and return it (minimum return of `handler` and `args`) +* `schedule()` accepts `handler`, `args`, `runAt`, `queue` and `priority` and should store the job +* `success()` accepts the job returned from `find()` and does whatever success means (delete) +* `failure()` accepts the job returned from `find()` and does whatever failure means (unlock and reschedule) +* `clear()` removes all jobs + ## The Future There's still more to add to background jobs! Our current TODO list: From 3c74c4fdc6d50b6c04908c639eb4819d1cab672e Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 18 Jul 2024 13:29:54 -0400 Subject: [PATCH 090/258] Remove migrate command, add to output message --- .../cli/src/commands/setup/jobs/jobsHandler.js | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/commands/setup/jobs/jobsHandler.js b/packages/cli/src/commands/setup/jobs/jobsHandler.js index b0f3f29f7c11..13b200f5664d 100644 --- a/packages/cli/src/commands/setup/jobs/jobsHandler.js +++ b/packages/cli/src/commands/setup/jobs/jobsHandler.js @@ -53,21 +53,6 @@ const tasks = async ({ force }) => { }, enabled: () => !modelExists, }, - { - title: 'Migrating database...', - task: () => { - execa.sync( - 'yarn rw prisma migrate dev', - ['--name', 'create-background-jobs'], - { - shell: true, - cwd: getPaths().base, - stdio: 'inherit', - }, - ) - }, - enabled: () => !modelExists, - }, { title: 'Creating config file in api/src/lib...', task: async () => { @@ -115,6 +100,9 @@ const tasks = async ({ force }) => { ${c.success('\nBackground jobs configured!\n')} + ${!modelExists ? 'Migrate your database to add the new `BackgroundJob` model:\n' : ''} + ${!modelExists ? c.warning('\n\u00A0\u00A0yarn rw prisma migrate dev\n') : ''} + Generate jobs with: ${c.warning('yarn rw g job ')} Execute jobs with: ${c.warning('yarn rw jobs work\n')} From 0a1946348aef1307b78fe2a22484fd3a63709706 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 18 Jul 2024 18:44:06 -0400 Subject: [PATCH 091/258] Update exit message --- packages/cli/src/commands/setup/jobs/jobsHandler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/setup/jobs/jobsHandler.js b/packages/cli/src/commands/setup/jobs/jobsHandler.js index 13b200f5664d..653841ea4d00 100644 --- a/packages/cli/src/commands/setup/jobs/jobsHandler.js +++ b/packages/cli/src/commands/setup/jobs/jobsHandler.js @@ -100,7 +100,7 @@ const tasks = async ({ force }) => { ${c.success('\nBackground jobs configured!\n')} - ${!modelExists ? 'Migrate your database to add the new `BackgroundJob` model:\n' : ''} + ${!modelExists ? 'Migrate your database to finish setting up jobs:\n' : ''} ${!modelExists ? c.warning('\n\u00A0\u00A0yarn rw prisma migrate dev\n') : ''} Generate jobs with: ${c.warning('yarn rw g job ')} From ecd1e322b37e87233cab0dc822c829c0cc0ac466 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 18 Jul 2024 18:48:12 -0400 Subject: [PATCH 092/258] Use skip instead of enable --- packages/cli/src/commands/setup/jobs/jobsHandler.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/setup/jobs/jobsHandler.js b/packages/cli/src/commands/setup/jobs/jobsHandler.js index 653841ea4d00..0b0433f825d0 100644 --- a/packages/cli/src/commands/setup/jobs/jobsHandler.js +++ b/packages/cli/src/commands/setup/jobs/jobsHandler.js @@ -51,7 +51,11 @@ const tasks = async ({ force }) => { task: () => { addModel() }, - enabled: () => !modelExists, + skip: () => { + if (modelExists) { + return 'BackgroundJob model exists, skipping' + } + }, }, { title: 'Creating config file in api/src/lib...', From aaa6a31e519dc52bfb7400a79ec00937a9a66518 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 18 Jul 2024 19:37:30 -0400 Subject: [PATCH 093/258] Pass the maxAttemps, maxRuntime and deleteFailedJobs options to the adapter --- packages/jobs/src/adapters/BaseAdapter.ts | 7 ++++++- packages/jobs/src/adapters/PrismaAdapter.ts | 14 ++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/jobs/src/adapters/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter.ts index 1812b70f6b46..46d98521e58b 100644 --- a/packages/jobs/src/adapters/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter.ts @@ -37,6 +37,11 @@ export interface BaseAdapterOptions { logger?: BasicLogger } +export interface FailureOptions { + maxAttempts: number + deleteFailedJobs: boolean +} + export abstract class BaseAdapter< TOptions extends BaseAdapterOptions = BaseAdapterOptions, > { @@ -61,5 +66,5 @@ export abstract class BaseAdapter< abstract success(job: BaseJob): any - abstract failure(job: BaseJob, error: Error): any + abstract failure(job: BaseJob, error: Error, options: FailureOptions): any } diff --git a/packages/jobs/src/adapters/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter.ts index c93698a82614..aef7cff04679 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter.ts @@ -35,6 +35,7 @@ import type { BaseAdapterOptions, FindArgs, SchedulePayload, + FailureOptions, } from './BaseAdapter' import { BaseAdapter } from './BaseAdapter' @@ -56,7 +57,6 @@ interface PrismaJob extends BaseJob { interface PrismaAdapterOptions extends BaseAdapterOptions { db: PrismaClient model?: string - maxAttempts?: number } interface FailureData { @@ -72,7 +72,6 @@ export class PrismaAdapter extends BaseAdapter { model: string accessor: PrismaClient[keyof PrismaClient] provider: string - maxAttempts: number constructor(options: PrismaAdapterOptions) { super(options) @@ -89,8 +88,6 @@ export class PrismaAdapter extends BaseAdapter { // the database provider type: 'sqlite' | 'postgresql' | 'mysql' this.provider = options.db._activeProvider - this.maxAttempts = options?.maxAttempts || DEFAULT_MAX_ATTEMPTS - // validate that everything we need is available if (!this.accessor) { throw new ModelNameError(this.model) @@ -196,8 +193,13 @@ export class PrismaAdapter extends BaseAdapter { return await this.accessor.delete({ where: { id: job.id } }) } - async failure(job: PrismaJob, error: Error) { + async failure(job: PrismaJob, error: Error, options: FailureOptions) { this.logger.debug(`Job ${job.id} failure`) + + if (job.attempts >= options.maxAttempts && options.deleteFailedJobs) { + return await this.accessor.delete({ where: { id: job.id } }) + } + const data: FailureData = { lockedAt: null, lockedBy: null, @@ -205,7 +207,7 @@ export class PrismaAdapter extends BaseAdapter { runAt: null, } - if (job.attempts >= this.maxAttempts) { + if (job.attempts >= options.maxAttempts) { data.failedAt = new Date() } else { data.runAt = new Date( From 35965f870b9830def9ea5b407e722bb8d91b5e9d Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 18 Jul 2024 21:14:33 -0400 Subject: [PATCH 094/258] Adds error for missing config file, show proper filename --- packages/jobs/src/core/errors.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/jobs/src/core/errors.ts b/packages/jobs/src/core/errors.ts index 9da6cf7789fb..5b9ce211e398 100644 --- a/packages/jobs/src/core/errors.ts +++ b/packages/jobs/src/core/errors.ts @@ -1,3 +1,7 @@ +import { isTypeScriptProject } from '@redwoodjs/cli-helpers' + +const JOBS_CONFIG_FILENAME = isTypeScriptProject() ? 'jobs.ts' : 'jobs.js' + // Parent class for any RedwoodJob-related error export class RedwoodJobError extends Error { constructor(message: string) { @@ -67,7 +71,7 @@ export class JobExportNotFoundError extends RedwoodJobError { export class JobsLibNotFoundError extends RedwoodJobError { constructor() { super( - 'api/src/lib/jobs.js not found. Run `yarn rw setup jobs` to create this file and configure background jobs', + `api/src/lib/${JOBS_CONFIG_FILENAME} not found. Run \`yarn rw setup jobs\` to create this file and configure background jobs`, ) } } @@ -75,7 +79,14 @@ export class JobsLibNotFoundError extends RedwoodJobError { // Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js export class AdapterNotFoundError extends RedwoodJobError { constructor() { - super('api/src/lib/jobs.js does not export `adapter`') + super(`api/src/lib/${JOBS_CONFIG_FILENAME} does not export \`adapter\``) + } +} + +// Thrown when the runner tries to import `workerConfig` from api/src/lib/jobs.js +export class WorkerConfigNotFoundError extends RedwoodJobError { + constructor(name: string) { + super(`api/src/lib/#{JOBS_CONFIG_FILENAME} does not export \`${name}\``) } } From 07805aa67c5bc18f7a80b8ad81d48dd8b90fa84f Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 18 Jul 2024 21:14:54 -0400 Subject: [PATCH 095/258] Adds loadWorkerConfig, removes loadAdapter and loadLogger --- packages/jobs/src/core/loaders.ts | 32 +++++++------------------------ 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/packages/jobs/src/core/loaders.ts b/packages/jobs/src/core/loaders.ts index 07cccd64c47d..f21e2e6c724b 100644 --- a/packages/jobs/src/core/loaders.ts +++ b/packages/jobs/src/core/loaders.ts @@ -1,4 +1,3 @@ -import console from 'node:console' import path from 'node:path' import { pathToFileURL } from 'node:url' @@ -8,7 +7,7 @@ import { registerApiSideBabelHook } from '@redwoodjs/babel-config' import { getPaths } from '@redwoodjs/project-config' import { - AdapterNotFoundError, + WorkerConfigNotFoundError, JobsLibNotFoundError, JobNotFoundError, } from './errors' @@ -20,39 +19,22 @@ export function makeFilePath(path: string) { return pathToFileURL(path).href } -// Loads the exported adapter from the app's jobs config in api/src/lib/jobs.{js,ts} -export const loadAdapter = async () => { +// Loads the named export from the app's jobs config in api/src/lib/jobs.{js,ts} +// to configure the worker, defaults to `workerConfig` +export const loadWorkerConfig = async (name = 'workerConfig') => { const jobsConfigPath = getPaths().api.jobsConfig if (jobsConfigPath) { const jobsModule = require(jobsConfigPath) - if (jobsModule.adapter) { - return jobsModule.adapter + if (jobsModule[name]) { + return jobsModule[name] } else { - throw new AdapterNotFoundError() + throw new WorkerConfigNotFoundError(name) } } else { throw new JobsLibNotFoundError() } } -// Loads the logger from the app's filesystem in api/src/lib/logger.{js,ts} -export const loadLogger = async () => { - const loggerPath = getPaths().api.logger - if (loggerPath) { - try { - const loggerModule = require(loggerPath) - return loggerModule.logger - } catch (e) { - console.warn( - 'Tried to load logger but failed, falling back to console\n', - e, - ) - } - } - - return console -} - // Loads a job from the app's filesystem in api/src/jobs export const loadJob = async (name: string) => { // Specifying {js,ts} extensions, so we don't accidentally try to load .json From 0cfc832a93ab96df50c0aad97db44748d24fd61b Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 18 Jul 2024 21:15:16 -0400 Subject: [PATCH 096/258] Get logger from workerConfig --- packages/jobs/src/bins/rw-jobs.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index b51dd3384246..e506ec01b6f6 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -14,7 +14,7 @@ import yargs from 'yargs/yargs' import { loadEnvFiles } from '@redwoodjs/cli-helpers/dist/lib/loadEnvFiles.js' -import { loadLogger } from '../core/loaders' +import { loadWorkerConfig } from '../core/loaders' import type { BasicLogger } from '../types' export type WorkerConfig = Array<[string | null, number]> // [queue, id] @@ -266,7 +266,7 @@ const clearQueue = ({ logger }: { logger: BasicLogger }) => { const main = async () => { const { workerDef, command } = parseArgs(process.argv) const workerConfig = buildWorkerConfig(workerDef) - const logger = await loadLogger() + const { logger } = await loadWorkerConfig() logger.warn(`Starting RedwoodJob Runner at ${new Date().toISOString()}...`) From 59854c29355f0bffce5af55fdfc30ba4199d1df2 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 18 Jul 2024 21:52:29 -0400 Subject: [PATCH 097/258] Passed named workerConfig to worker script --- packages/jobs/src/bins/rw-jobs-worker.ts | 30 ++++++++++++++++-------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index 611f594d06c9..28400e4536f0 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -3,13 +3,17 @@ // The process that actually starts an instance of Worker to process jobs. // Can be run independently with `yarn rw-jobs-worker` but by default is forked // by `yarn rw-jobs` and either monitored, or detached to run independently. - +// +// If you want to get fancy and have different workers running with different +// configurations, you need to invoke this script manually and pass the --config +// option with the name of the named export from api/src/lib/jobs.js +import console from 'node:console' import process from 'node:process' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' -import { loadAdapter, loadLogger } from '../core/loaders' +import { loadWorkerConfig } from '../core/loaders' import { Worker } from '../core/Worker' import type { BasicLogger } from '../types' @@ -38,8 +42,14 @@ const parseArgs = (argv: string[]) => { default: false, description: 'Work off all jobs in the queue and exit', }) - .option('clear', { + .option('config', { alias: 'c', + type: 'string', + default: 'workerConfig', + description: 'Name of the exported variable containing the worker config', + }) + .option('clear', { + alias: 'd', type: 'boolean', default: false, description: 'Remove all jobs in the queue and exit', @@ -94,27 +104,27 @@ const setupSignals = ({ } const main = async () => { - const { id, queue, clear, workoff } = await parseArgs(process.argv) + const { id, queue, config, clear, workoff } = await parseArgs(process.argv) setProcessTitle({ id, queue }) - const logger = await loadLogger() - let adapter + let workerConfig try { - adapter = await loadAdapter() + workerConfig = await loadWorkerConfig(config) } catch (e) { - logger.error(e) + console.error(e) process.exit(1) } + const logger = workerConfig.logger || console + logger.info( `[${process.title}] Starting work at ${new Date().toISOString()}...`, ) const worker = new Worker({ - adapter, + ...workerConfig, processName: process.title, - logger, queue, workoff, clear, From a546ffdb7e73bbb0bf62b9e80afb21db90546c23 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 18 Jul 2024 21:52:51 -0400 Subject: [PATCH 098/258] Adds additional options and passes them to Executor --- packages/jobs/src/core/Worker.ts | 68 +++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/packages/jobs/src/core/Worker.ts b/packages/jobs/src/core/Worker.ts index 04a37119d37a..40bc75441d62 100644 --- a/packages/jobs/src/core/Worker.ts +++ b/packages/jobs/src/core/Worker.ts @@ -13,17 +13,29 @@ import { Executor } from './Executor' interface WorkerOptions { adapter: BaseAdapter logger?: BasicLogger + maxAttempts: number + maxRuntime: number + deleteFailedJobs: boolean + sleepDelay: number clear?: boolean processName?: string queue?: string | null - maxRuntime?: number waitTime?: number forever?: boolean workoff?: boolean } -export const DEFAULT_WAIT_TIME = 5000 // 5 seconds -export const DEFAULT_MAX_RUNTIME = 60 * 60 * 4 * 1000 // 4 hours +export const DEFAULTS = { + logger: console, + processName: process.title, + queue: null, + maxAttempts: 24, + maxRuntime: 14_400, // 4 hours in seconds + sleepDelay: 5, // 5 seconds + deleteFailedJobs: false, + forever: true, + workoff: false, +} export class Worker { options: WorkerOptions @@ -32,14 +44,21 @@ export class Worker { clear: boolean processName: string queue: string | null + maxAttempts: number maxRuntime: number - waitTime: number + deleteFailedJobs: boolean + sleepDelay: number lastCheckTime: Date forever: boolean workoff: boolean constructor(options: WorkerOptions) { this.options = options + + if (!options.adapter) { + throw new AdapterRequiredError() + } + this.adapter = options?.adapter this.logger = options?.logger || console @@ -47,41 +66,42 @@ export class Worker { this.clear = options?.clear || false // used to set the `lockedBy` field in the database - this.processName = options?.processName || process.title + this.processName = options?.processName || DEFAULTS.processName // if not given a queue name then will work on jobs in any queue - this.queue = options?.queue || null + this.queue = options?.queue || DEFAULTS.queue + + // the maximum number of times to retry a failed job + this.maxAttempts = options.maxAttempts || DEFAULTS.maxAttempts // the maximum amount of time to let a job run - this.maxRuntime = - options?.maxRuntime === undefined - ? DEFAULT_MAX_RUNTIME - : options.maxRuntime + this.maxRuntime = options.maxRuntime || DEFAULTS.maxRuntime + + // whether to keep failed jobs in the database after reaching maxAttempts + this.deleteFailedJobs = + options.deleteFailedJobs || DEFAULTS.deleteFailedJobs // the amount of time to wait in milliseconds between checking for jobs. // the time it took to run a job is subtracted from this time, so this is a // maximum wait time - this.waitTime = - options?.waitTime === undefined ? DEFAULT_WAIT_TIME : options.waitTime - - // keep track of the last time we checked for jobs - this.lastCheckTime = new Date() + this.sleepDelay = options.sleepDelay || DEFAULTS.sleepDelay // Set to `false` if the work loop should only run one time, regardless // of how many outstanding jobs there are to be worked on. The worker // process will set this to `false` as soon as the user hits ctrl-c so // any current job will complete before exiting. - this.forever = options?.forever === undefined ? true : options.forever + this.forever = + options?.forever === undefined ? DEFAULTS.forever : options.forever // Set to `true` if the work loop should run through all *available* jobs // and then quit. Serves a slightly different purpose than `forever` which // makes the runner exit immediately after the next loop, where as `workoff` // doesn't exit the loop until there are no more jobs to work on. - this.workoff = options?.workoff === undefined ? false : options.workoff + this.workoff = + options?.workoff === undefined ? DEFAULTS.workoff : options.workoff - if (!this.adapter) { - throw new AdapterRequiredError() - } + // keep track of the last time we checked for jobs + this.lastCheckTime = new Date() } // Workers run forever unless: @@ -117,8 +137,10 @@ export class Worker { // TODO add timeout handling if runs for more than `this.maxRuntime` await new Executor({ adapter: this.adapter, - job, logger: this.logger, + job, + maxAttempts: this.maxAttempts, + deleteFailedJobs: this.deleteFailedJobs, }).perform() } else if (this.workoff) { // If there are no jobs and we're in workoff mode, we're done @@ -129,8 +151,8 @@ export class Worker { if (!job && this.forever) { const millsSinceLastCheck = new Date().getTime() - this.lastCheckTime.getTime() - if (millsSinceLastCheck < this.waitTime) { - await this.#wait(this.waitTime - millsSinceLastCheck) + if (millsSinceLastCheck < this.sleepDelay) { + await this.#wait(this.sleepDelay - millsSinceLastCheck) } } } while (this.forever) From 2ff2c05d8d42431d27bde22e5b20e39a61ba2eed Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 18 Jul 2024 21:53:17 -0400 Subject: [PATCH 099/258] Accept new options and use --- packages/jobs/src/core/Executor.ts | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/jobs/src/core/Executor.ts b/packages/jobs/src/core/Executor.ts index 137ab517c680..a998c65a30cd 100644 --- a/packages/jobs/src/core/Executor.ts +++ b/packages/jobs/src/core/Executor.ts @@ -14,36 +14,42 @@ import { loadJob } from './loaders' interface Options { adapter: BaseAdapter - job: any logger?: BasicLogger + job: any + maxAttempts: number + deleteFailedJobs: boolean } export class Executor { options: Options adapter: BaseAdapter - job: any | null logger: BasicLogger + job: any | null + maxAttempts: number + deleteFailedJobs: boolean constructor(options: Options) { this.options = options - this.adapter = options.adapter - this.job = options.job - this.logger = options.logger || console // validate that everything we need is available - if (!this.adapter) { + if (!options.adapter) { throw new AdapterRequiredError() } - if (!this.job) { + if (!options.job) { throw new JobRequiredError() } + + this.adapter = options.adapter + this.logger = options.logger || console + this.job = options.job + this.maxAttempts = options.maxAttempts + this.deleteFailedJobs = options.deleteFailedJobs } async perform() { this.logger.info(this.job, `Started job ${this.job.id}`) const details = JSON.parse(this.job.handler) - // TODO try to use the adapter defined by the job itself, otherwise fallback to this.adapter try { const jobModule = await loadJob(details.handler) await new jobModule[details.handler]().perform(...details.args) @@ -54,7 +60,10 @@ export class Executor { error = new JobExportNotFoundError(details.handler) } this.logger.error(error.stack) - return this.adapter.failure(this.job, error) + return this.adapter.failure(this.job, error, { + maxAttempts: this.maxAttempts, + deleteFailedJobs: this.deleteFailedJobs, + }) } } } From 88fa4a731abb8e23b385315be58718f9ccef3730 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 18 Jul 2024 22:15:24 -0400 Subject: [PATCH 100/258] Updates PrismaAdapter tests --- packages/jobs/src/adapters/BaseAdapter.ts | 4 +- packages/jobs/src/adapters/PrismaAdapter.ts | 30 +++++++--- .../adapters/__tests__/PrismaAdapter.test.js | 57 +++++++++++-------- 3 files changed, 58 insertions(+), 33 deletions(-) diff --git a/packages/jobs/src/adapters/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter.ts index 46d98521e58b..27600ef76640 100644 --- a/packages/jobs/src/adapters/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter.ts @@ -38,8 +38,8 @@ export interface BaseAdapterOptions { } export interface FailureOptions { - maxAttempts: number - deleteFailedJobs: boolean + maxAttempts?: number + deleteFailedJobs?: boolean } export abstract class BaseAdapter< diff --git a/packages/jobs/src/adapters/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter.ts index aef7cff04679..0f97472b6306 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter.ts @@ -39,8 +39,12 @@ import type { } from './BaseAdapter' import { BaseAdapter } from './BaseAdapter' -export const DEFAULT_MODEL_NAME = 'BackgroundJob' -export const DEFAULT_MAX_ATTEMPTS = 24 +export const DEFAULTS = { + model: 'BackgroundJob', + maxAttempts: 24, + maxRuntime: 14_400, + deleteFailedJobs: false, +} interface PrismaJob extends BaseJob { id: number @@ -80,7 +84,7 @@ export class PrismaAdapter extends BaseAdapter { this.db = options.db // name of the model as defined in schema.prisma - this.model = options.model || DEFAULT_MODEL_NAME + this.model = options.model || DEFAULTS.model // the function to call on `db` to make queries: `db.backgroundJob` this.accessor = this.db[camelCase(this.model)] @@ -106,7 +110,9 @@ export class PrismaAdapter extends BaseAdapter { maxRuntime, queue, }: FindArgs): Promise { - const maxRuntimeExpire = new Date(new Date().getTime() + maxRuntime) + const maxRuntimeExpire = new Date( + new Date().getTime() + (maxRuntime || DEFAULTS.maxRuntime * 1000), + ) // This query is gnarly but not so bad once you know what it's doing. For a // job to match it must: @@ -193,10 +199,20 @@ export class PrismaAdapter extends BaseAdapter { return await this.accessor.delete({ where: { id: job.id } }) } - async failure(job: PrismaJob, error: Error, options: FailureOptions) { + async failure(job: PrismaJob, error: Error, options?: FailureOptions) { this.logger.debug(`Job ${job.id} failure`) - if (job.attempts >= options.maxAttempts && options.deleteFailedJobs) { + // since booleans don't play nicely with || we'll explicitly check for + // `undefined` before falling back to the default + const shouldDeleteFailed = + options?.deleteFailedJobs === undefined + ? DEFAULTS.deleteFailedJobs + : options.deleteFailedJobs + + if ( + job.attempts >= (options?.maxAttempts || DEFAULTS.maxAttempts) && + shouldDeleteFailed + ) { return await this.accessor.delete({ where: { id: job.id } }) } @@ -207,7 +223,7 @@ export class PrismaAdapter extends BaseAdapter { runAt: null, } - if (job.attempts >= options.maxAttempts) { + if (job.attempts >= (options?.maxAttempts || DEFAULTS.maxAttempts)) { data.failedAt = new Date() } else { data.runAt = new Date( diff --git a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js index 162c5eeba290..d81e5cd411dc 100644 --- a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js +++ b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js @@ -1,14 +1,19 @@ import { describe, expect, vi, it, beforeEach, afterEach } from 'vitest' import * as errors from '../../core/errors' -import { - PrismaAdapter, - DEFAULT_MODEL_NAME, - DEFAULT_MAX_ATTEMPTS, -} from '../PrismaAdapter' +import { PrismaAdapter, DEFAULTS } from '../PrismaAdapter' vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) +vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { + const originalCliHelpers = await importOriginal() + + return { + ...originalCliHelpers, + isTypeScriptProject: () => false, + } +}) + let mockDb beforeEach(() => { @@ -40,7 +45,7 @@ describe('constructor', () => { it('defaults this.model name', () => { const adapter = new PrismaAdapter({ db: mockDb }) - expect(adapter.model).toEqual(DEFAULT_MODEL_NAME) + expect(adapter.model).toEqual(DEFAULTS.model) }) it('can manually set this.model', () => { @@ -76,18 +81,6 @@ describe('constructor', () => { expect(adapter.provider).toEqual('sqlite') }) - - it('defaults this.maxAttempts', () => { - const adapter = new PrismaAdapter({ db: mockDb }) - - expect(adapter.maxAttempts).toEqual(DEFAULT_MAX_ATTEMPTS) - }) - - it('allows manually setting this.maxAttempts', () => { - const adapter = new PrismaAdapter({ db: mockDb, maxAttempts: 10 }) - - expect(adapter.maxAttempts).toEqual(10) - }) }) describe('schedule()', () => { @@ -267,33 +260,49 @@ describe('failure()', () => { ) }) - it('marks the job as failed if max attempts reached', async () => { + it('nullifies runtAt if max attempts reached', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') const adapter = new PrismaAdapter({ db: mockDb }) - await adapter.failure({ id: 1, attempts: 24 }, new Error('test error')) + await adapter.failure({ id: 1, attempts: 10 }, new Error('test error'), { + maxAttempts: 10, + }) expect(spy).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ - failedAt: new Date(), + runAt: null, }), }), ) }) - it('nullifies runtAt if max attempts reached', async () => { + it('marks the job as failed if max attempts reached', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') const adapter = new PrismaAdapter({ db: mockDb }) - await adapter.failure({ id: 1, attempts: 24 }, new Error('test error')) + await adapter.failure({ id: 1, attempts: 10 }, new Error('test error'), { + maxAttempts: 10, + deleteFailedJobs: false, + }) expect(spy).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ - runAt: null, + failedAt: new Date(), }), }), ) }) + + it('deletes the job if max attempts reached and deleteFailedJobs set to true', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'delete') + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.failure({ id: 1, attempts: 10 }, new Error('test error'), { + maxAttempts: 10, + deleteFailedJobs: true, + }) + + expect(spy).toHaveBeenCalledWith({ where: { id: 1 } }) + }) }) describe('clear()', () => { From 0e2d0c7df40ea4bd9bd3b37b6b5ea2edbbacf902 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 18 Jul 2024 22:15:38 -0400 Subject: [PATCH 101/258] Make sure sleepDelay is turned into milliseconds --- packages/jobs/src/core/Worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jobs/src/core/Worker.ts b/packages/jobs/src/core/Worker.ts index 40bc75441d62..2f305af0b994 100644 --- a/packages/jobs/src/core/Worker.ts +++ b/packages/jobs/src/core/Worker.ts @@ -84,7 +84,7 @@ export class Worker { // the amount of time to wait in milliseconds between checking for jobs. // the time it took to run a job is subtracted from this time, so this is a // maximum wait time - this.sleepDelay = options.sleepDelay || DEFAULTS.sleepDelay + this.sleepDelay = (options.sleepDelay || DEFAULTS.sleepDelay) * 1000 // Set to `false` if the work loop should only run one time, regardless // of how many outstanding jobs there are to be worked on. The worker From ada86e94d26ee37c3fbc39eec2683b1cacba4d4d Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 18 Jul 2024 22:29:35 -0400 Subject: [PATCH 102/258] Updates Worker, Executor tests --- packages/jobs/src/core/Executor.ts | 1 + packages/jobs/src/core/Worker.ts | 24 ++++--- .../jobs/src/core/__tests__/Executor.test.js | 9 +++ .../jobs/src/core/__tests__/Worker.test.js | 71 +++++++++++++++---- 4 files changed, 83 insertions(+), 22 deletions(-) diff --git a/packages/jobs/src/core/Executor.ts b/packages/jobs/src/core/Executor.ts index a998c65a30cd..1fcf8067ebbd 100644 --- a/packages/jobs/src/core/Executor.ts +++ b/packages/jobs/src/core/Executor.ts @@ -50,6 +50,7 @@ export class Executor { this.logger.info(this.job, `Started job ${this.job.id}`) const details = JSON.parse(this.job.handler) + // TODO break these lines down into individual try/catch blocks? try { const jobModule = await loadJob(details.handler) await new jobModule[details.handler]().perform(...details.args) diff --git a/packages/jobs/src/core/Worker.ts b/packages/jobs/src/core/Worker.ts index 2f305af0b994..d1c89e1605ed 100644 --- a/packages/jobs/src/core/Worker.ts +++ b/packages/jobs/src/core/Worker.ts @@ -55,21 +55,21 @@ export class Worker { constructor(options: WorkerOptions) { this.options = options - if (!options.adapter) { + if (!options?.adapter) { throw new AdapterRequiredError() } - this.adapter = options?.adapter - this.logger = options?.logger || console + this.adapter = options.adapter + this.logger = options.logger || console // if true, will clear the queue of all jobs and then exit - this.clear = options?.clear || false + this.clear = options.clear || false // used to set the `lockedBy` field in the database - this.processName = options?.processName || DEFAULTS.processName + this.processName = options.processName || DEFAULTS.processName // if not given a queue name then will work on jobs in any queue - this.queue = options?.queue || DEFAULTS.queue + this.queue = options.queue || DEFAULTS.queue // the maximum number of times to retry a failed job this.maxAttempts = options.maxAttempts || DEFAULTS.maxAttempts @@ -78,13 +78,19 @@ export class Worker { this.maxRuntime = options.maxRuntime || DEFAULTS.maxRuntime // whether to keep failed jobs in the database after reaching maxAttempts + // `undefined` check needed here so we can explicitly set to `false` this.deleteFailedJobs = - options.deleteFailedJobs || DEFAULTS.deleteFailedJobs + options.deleteFailedJobs === undefined + ? DEFAULTS.deleteFailedJobs + : options.deleteFailedJobs // the amount of time to wait in milliseconds between checking for jobs. // the time it took to run a job is subtracted from this time, so this is a - // maximum wait time - this.sleepDelay = (options.sleepDelay || DEFAULTS.sleepDelay) * 1000 + // maximum wait time. Do an `undefined` check here so we can set to 0 + this.sleepDelay = + (options.sleepDelay === undefined + ? DEFAULTS.sleepDelay + : options.sleepDelay) * 1000 // Set to `false` if the work loop should only run one time, regardless // of how many outstanding jobs there are to be worked on. The worker diff --git a/packages/jobs/src/core/__tests__/Executor.test.js b/packages/jobs/src/core/__tests__/Executor.test.js index 32066a318dde..3fabf624205d 100644 --- a/packages/jobs/src/core/__tests__/Executor.test.js +++ b/packages/jobs/src/core/__tests__/Executor.test.js @@ -6,6 +6,15 @@ import { Executor } from '../Executor' // so that registerApiSideBabelHook() doesn't freak out about redwood.toml vi.mock('@redwoodjs/babel-config') +vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { + const originalCliHelpers = await importOriginal() + + return { + ...originalCliHelpers, + isTypeScriptProject: () => false, + } +}) + describe('constructor', () => { it('saves options', () => { const options = { adapter: 'adapter', job: 'job' } diff --git a/packages/jobs/src/core/__tests__/Worker.test.js b/packages/jobs/src/core/__tests__/Worker.test.js index 972102f72da5..25515cf6203d 100644 --- a/packages/jobs/src/core/__tests__/Worker.test.js +++ b/packages/jobs/src/core/__tests__/Worker.test.js @@ -10,7 +10,7 @@ import { import * as errors from '../../core/errors' import { Executor } from '../Executor' -import { Worker, DEFAULT_MAX_RUNTIME, DEFAULT_WAIT_TIME } from '../Worker' +import { Worker, DEFAULTS } from '../Worker' // don't execute any code inside Executor, just spy on whether functions are // called @@ -19,6 +19,15 @@ vi.mock('../Executor') // so that registerApiSideBabelHook() doesn't freak out about redwood.toml vi.mock('@redwoodjs/babel-config') +vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { + const originalCliHelpers = await importOriginal() + + return { + ...originalCliHelpers, + isTypeScriptProject: () => false, + } +}) + vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) describe('constructor', () => { @@ -64,39 +73,67 @@ describe('constructor', () => { expect(worker.processName).not.toBeUndefined() }) + it('extracts maxAttempts from options to variable', () => { + const options = { adapter: 'adapter', maxAttempts: 10 } + const worker = new Worker(options) + + expect(worker.maxAttempts).toEqual(10) + }) + + it('sets default maxAttempts if not provided', () => { + const options = { adapter: 'adapter' } + const worker = new Worker(options) + + expect(worker.maxAttempts).toEqual(DEFAULTS.maxAttempts) + }) + it('extracts maxRuntime from options to variable', () => { - const options = { adapter: 'adapter', maxRuntime: 1000 } + const options = { adapter: 'adapter', maxRuntime: 10 } const worker = new Worker(options) - expect(worker.maxRuntime).toEqual(1000) + expect(worker.maxRuntime).toEqual(10) }) it('sets default maxRuntime if not provided', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) - expect(worker.maxRuntime).toEqual(DEFAULT_MAX_RUNTIME) + expect(worker.maxRuntime).toEqual(DEFAULTS.maxRuntime) }) - it('extracts waitTime from options to variable', () => { - const options = { adapter: 'adapter', waitTime: 1000 } + it('extracts deleteFailedJobs from options to variable', () => { + const options = { adapter: 'adapter', deleteFailedJobs: 10 } const worker = new Worker(options) - expect(worker.waitTime).toEqual(1000) + expect(worker.deleteFailedJobs).toEqual(10) }) - it('sets default waitTime if not provided', () => { + it('sets default deleteFailedJobs if not provided', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) - expect(worker.waitTime).toEqual(DEFAULT_WAIT_TIME) + expect(worker.deleteFailedJobs).toEqual(DEFAULTS.deleteFailedJobs) }) - it('can set waitTime to 0', () => { - const options = { adapter: 'adapter', waitTime: 0 } + it('extracts sleepDelay from options to variable', () => { + const options = { adapter: 'adapter', sleepDelay: 5 } const worker = new Worker(options) - expect(worker.waitTime).toEqual(0) + expect(worker.sleepDelay).toEqual(5000) + }) + + it('sets default sleepDelay if not provided', () => { + const options = { adapter: 'adapter' } + const worker = new Worker(options) + + expect(worker.sleepDelay).toEqual(DEFAULTS.sleepDelay * 1000) + }) + + it('can set sleepDelay to 0', () => { + const options = { adapter: 'adapter', sleepDelay: 0 } + const worker = new Worker(options) + + expect(worker.sleepDelay).toEqual(0) }) it('sets lastCheckTime to the current time', () => { @@ -177,7 +214,13 @@ describe('run', () => { it('initializes an Executor instance if the job is found', async () => { const adapter = { find: vi.fn(() => ({ id: 1 })) } - const worker = new Worker({ adapter, waitTime: 0, forever: false }) + const worker = new Worker({ + adapter, + waitTime: 0, + forever: false, + maxAttempts: 10, + deleteFailedJobs: true, + }) await worker.run() @@ -185,6 +228,8 @@ describe('run', () => { adapter, job: { id: 1 }, logger: worker.logger, + maxAttempts: 10, + deleteFailedJobs: true, }) }) From 7427ca7c1a56c52d8782e3af699bfdb38087957b Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 18 Jul 2024 22:29:44 -0400 Subject: [PATCH 103/258] Updates RedwoodJob tests --- packages/jobs/src/core/__tests__/RedwoodJob.test.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/jobs/src/core/__tests__/RedwoodJob.test.js b/packages/jobs/src/core/__tests__/RedwoodJob.test.js index 6eb74124bce7..ed42b1ac36c8 100644 --- a/packages/jobs/src/core/__tests__/RedwoodJob.test.js +++ b/packages/jobs/src/core/__tests__/RedwoodJob.test.js @@ -3,6 +3,15 @@ import { describe, expect, vi, it, beforeEach } from 'vitest' import * as errors from '../../core/errors' import { RedwoodJob } from '../RedwoodJob' +vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { + const originalCliHelpers = await importOriginal() + + return { + ...originalCliHelpers, + isTypeScriptProject: () => false, + } +}) + vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) describe('static config', () => { From 46ce8a5ff5063f5d04d666cef7ccb91d834cdcf3 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 18 Jul 2024 22:42:31 -0400 Subject: [PATCH 104/258] Add tests for new worker default settings --- packages/jobs/src/core/Worker.ts | 57 +++++++------ .../jobs/src/core/__tests__/Worker.test.js | 80 ++++++++++++++++--- 2 files changed, 101 insertions(+), 36 deletions(-) diff --git a/packages/jobs/src/core/Worker.ts b/packages/jobs/src/core/Worker.ts index d1c89e1605ed..bef3ad80086c 100644 --- a/packages/jobs/src/core/Worker.ts +++ b/packages/jobs/src/core/Worker.ts @@ -13,10 +13,10 @@ import { Executor } from './Executor' interface WorkerOptions { adapter: BaseAdapter logger?: BasicLogger - maxAttempts: number - maxRuntime: number - deleteFailedJobs: boolean - sleepDelay: number + maxAttempts?: number + maxRuntime?: number + deleteFailedJobs?: boolean + sleepDelay?: number clear?: boolean processName?: string queue?: string | null @@ -25,10 +25,25 @@ interface WorkerOptions { workoff?: boolean } +interface WorkerOptionsWithDefaults extends WorkerOptions { + logger: BasicLogger + maxAttempts: number + maxRuntime: number + deleteFailedJobs: boolean + sleepDelay: number + clear: boolean + processName: string + queue: string | null + waitTime: number + forever: boolean + workoff: boolean +} + export const DEFAULTS = { logger: console, processName: process.title, queue: null, + clear: false, maxAttempts: 24, maxRuntime: 14_400, // 4 hours in seconds sleepDelay: 5, // 5 seconds @@ -38,7 +53,7 @@ export const DEFAULTS = { } export class Worker { - options: WorkerOptions + options: WorkerOptionsWithDefaults adapter: BaseAdapter logger: BasicLogger clear: boolean @@ -53,58 +68,50 @@ export class Worker { workoff: boolean constructor(options: WorkerOptions) { - this.options = options + this.options = { ...DEFAULTS, ...options } as WorkerOptionsWithDefaults if (!options?.adapter) { throw new AdapterRequiredError() } - this.adapter = options.adapter - this.logger = options.logger || console + this.adapter = this.options.adapter + this.logger = this.options.logger // if true, will clear the queue of all jobs and then exit - this.clear = options.clear || false + this.clear = this.options.clear // used to set the `lockedBy` field in the database - this.processName = options.processName || DEFAULTS.processName + this.processName = this.options.processName // if not given a queue name then will work on jobs in any queue - this.queue = options.queue || DEFAULTS.queue + this.queue = this.options.queue // the maximum number of times to retry a failed job - this.maxAttempts = options.maxAttempts || DEFAULTS.maxAttempts + this.maxAttempts = this.options.maxAttempts // the maximum amount of time to let a job run - this.maxRuntime = options.maxRuntime || DEFAULTS.maxRuntime + this.maxRuntime = this.options.maxRuntime // whether to keep failed jobs in the database after reaching maxAttempts // `undefined` check needed here so we can explicitly set to `false` - this.deleteFailedJobs = - options.deleteFailedJobs === undefined - ? DEFAULTS.deleteFailedJobs - : options.deleteFailedJobs + this.deleteFailedJobs = this.options.deleteFailedJobs // the amount of time to wait in milliseconds between checking for jobs. // the time it took to run a job is subtracted from this time, so this is a // maximum wait time. Do an `undefined` check here so we can set to 0 - this.sleepDelay = - (options.sleepDelay === undefined - ? DEFAULTS.sleepDelay - : options.sleepDelay) * 1000 + this.sleepDelay = this.options.sleepDelay * 1000 // Set to `false` if the work loop should only run one time, regardless // of how many outstanding jobs there are to be worked on. The worker // process will set this to `false` as soon as the user hits ctrl-c so // any current job will complete before exiting. - this.forever = - options?.forever === undefined ? DEFAULTS.forever : options.forever + this.forever = this.options.forever // Set to `true` if the work loop should run through all *available* jobs // and then quit. Serves a slightly different purpose than `forever` which // makes the runner exit immediately after the next loop, where as `workoff` // doesn't exit the loop until there are no more jobs to work on. - this.workoff = - options?.workoff === undefined ? DEFAULTS.workoff : options.workoff + this.workoff = this.options.workoff // keep track of the last time we checked for jobs this.lastCheckTime = new Date() diff --git a/packages/jobs/src/core/__tests__/Worker.test.js b/packages/jobs/src/core/__tests__/Worker.test.js index 25515cf6203d..3d17e6d50067 100644 --- a/packages/jobs/src/core/__tests__/Worker.test.js +++ b/packages/jobs/src/core/__tests__/Worker.test.js @@ -1,3 +1,5 @@ +import console from 'node:console' + import { describe, expect, @@ -35,7 +37,7 @@ describe('constructor', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) - expect(worker.options).toEqual(options) + expect(worker.options.adapter).toEqual(options.adapter) }) it('extracts adapter from options to variable', () => { @@ -45,18 +47,18 @@ describe('constructor', () => { expect(worker.adapter).toEqual('adapter') }) - it('extracts queue from options to variable', () => { - const options = { adapter: 'adapter', queue: 'queue' } + it('extracts logger from options to variable', () => { + const options = { adapter: 'adapter', logger: { foo: 'bar' } } const worker = new Worker(options) - expect(worker.queue).toEqual('queue') + expect(worker.logger).toEqual({ foo: 'bar' }) }) - it('queue will be null if no queue specified', () => { + it('defaults logger if not provided', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) - expect(worker.queue).toBeNull() + expect(worker.logger).toEqual(console) }) it('extracts processName from options to variable', () => { @@ -73,6 +75,34 @@ describe('constructor', () => { expect(worker.processName).not.toBeUndefined() }) + it('extracts queue from options to variable', () => { + const options = { adapter: 'adapter', queue: 'queue' } + const worker = new Worker(options) + + expect(worker.queue).toEqual('queue') + }) + + it('defaults queue if not provided', () => { + const options = { adapter: 'adapter' } + const worker = new Worker(options) + + expect(worker.queue).toBeNull() + }) + + it('extracts clear from options to variable', () => { + const options = { adapter: 'adapter', clear: true } + const worker = new Worker(options) + + expect(worker.clear).toEqual(true) + }) + + it('defaults clear if not provided', () => { + const options = { adapter: 'adapter' } + const worker = new Worker(options) + + expect(worker.clear).toEqual(false) + }) + it('extracts maxAttempts from options to variable', () => { const options = { adapter: 'adapter', maxAttempts: 10 } const worker = new Worker(options) @@ -80,7 +110,7 @@ describe('constructor', () => { expect(worker.maxAttempts).toEqual(10) }) - it('sets default maxAttempts if not provided', () => { + it('defaults maxAttempts if not provided', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) @@ -94,7 +124,7 @@ describe('constructor', () => { expect(worker.maxRuntime).toEqual(10) }) - it('sets default maxRuntime if not provided', () => { + it('defaults maxRuntime if not provided', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) @@ -108,7 +138,7 @@ describe('constructor', () => { expect(worker.deleteFailedJobs).toEqual(10) }) - it('sets default deleteFailedJobs if not provided', () => { + it('defaults deleteFailedJobs if not provided', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) @@ -122,7 +152,7 @@ describe('constructor', () => { expect(worker.sleepDelay).toEqual(5000) }) - it('sets default sleepDelay if not provided', () => { + it('defaults sleepDelay if not provided', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) @@ -136,6 +166,34 @@ describe('constructor', () => { expect(worker.sleepDelay).toEqual(0) }) + it('extracts forever from options to variable', () => { + const options = { adapter: 'adapter', forever: false } + const worker = new Worker(options) + + expect(worker.forever).toEqual(false) + }) + + it('defaults forever if not provided', () => { + const options = { adapter: 'adapter' } + const worker = new Worker(options) + + expect(worker.forever).toEqual(true) + }) + + it('extracts workoff from options to variable', () => { + const options = { adapter: 'adapter', workoff: true } + const worker = new Worker(options) + + expect(worker.workoff).toEqual(true) + }) + + it('defaults workoff if not provided', () => { + const options = { adapter: 'adapter' } + const worker = new Worker(options) + + expect(worker.workoff).toEqual(false) + }) + it('sets lastCheckTime to the current time', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) @@ -150,7 +208,7 @@ describe('constructor', () => { expect(worker.forever).toEqual(false) }) - it('sets forever to `true` by default', () => { + it('defaults forever if not provided', () => { const options = { adapter: 'adapter' } const worker = new Worker(options) From 20730b4b0099705207a977a53946ecd1e34f3431 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 23 Jul 2024 16:13:41 -0700 Subject: [PATCH 105/258] Update Executor to use merged default options --- packages/jobs/src/core/Executor.ts | 33 ++++++++++++------- packages/jobs/src/core/Worker.ts | 7 ++-- .../jobs/src/core/__tests__/Executor.test.js | 18 +++++++++- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/packages/jobs/src/core/Executor.ts b/packages/jobs/src/core/Executor.ts index 1fcf8067ebbd..eb52fc459230 100644 --- a/packages/jobs/src/core/Executor.ts +++ b/packages/jobs/src/core/Executor.ts @@ -11,8 +11,9 @@ import { JobExportNotFoundError, } from './errors' import { loadJob } from './loaders' +import { DEFAULT_MAX_ATTEMPTS, DEFAULT_DELETE_FAILED_JOBS } from './Worker' -interface Options { +interface ExecutorOptions { adapter: BaseAdapter logger?: BasicLogger job: any @@ -20,30 +21,40 @@ interface Options { deleteFailedJobs: boolean } +interface ExecutorOptionsWithDefaults extends ExecutorOptions { + logger: BasicLogger +} + +export const DEFAULTS = { + logger: console, + maxAttempts: DEFAULT_MAX_ATTEMPTS, + deleteFailedJobs: DEFAULT_DELETE_FAILED_JOBS, +} + export class Executor { - options: Options + options: ExecutorOptionsWithDefaults adapter: BaseAdapter logger: BasicLogger job: any | null maxAttempts: number deleteFailedJobs: boolean - constructor(options: Options) { - this.options = options + constructor(options: ExecutorOptions) { + this.options = { ...DEFAULTS, ...options } as ExecutorOptionsWithDefaults // validate that everything we need is available - if (!options.adapter) { + if (!this.options.adapter) { throw new AdapterRequiredError() } - if (!options.job) { + if (!this.options.job) { throw new JobRequiredError() } - this.adapter = options.adapter - this.logger = options.logger || console - this.job = options.job - this.maxAttempts = options.maxAttempts - this.deleteFailedJobs = options.deleteFailedJobs + this.adapter = this.options.adapter + this.logger = this.options.logger + this.job = this.options.job + this.maxAttempts = this.options.maxAttempts + this.deleteFailedJobs = this.options.deleteFailedJobs } async perform() { diff --git a/packages/jobs/src/core/Worker.ts b/packages/jobs/src/core/Worker.ts index bef3ad80086c..67c952a372e4 100644 --- a/packages/jobs/src/core/Worker.ts +++ b/packages/jobs/src/core/Worker.ts @@ -39,15 +39,18 @@ interface WorkerOptionsWithDefaults extends WorkerOptions { workoff: boolean } +export const DEFAULT_MAX_ATTEMPTS = 24 +export const DEFAULT_DELETE_FAILED_JOBS = false + export const DEFAULTS = { logger: console, processName: process.title, queue: null, clear: false, - maxAttempts: 24, + maxAttempts: DEFAULT_MAX_ATTEMPTS, maxRuntime: 14_400, // 4 hours in seconds sleepDelay: 5, // 5 seconds - deleteFailedJobs: false, + deleteFailedJobs: DEFAULT_DELETE_FAILED_JOBS, forever: true, workoff: false, } diff --git a/packages/jobs/src/core/__tests__/Executor.test.js b/packages/jobs/src/core/__tests__/Executor.test.js index 3fabf624205d..459335543512 100644 --- a/packages/jobs/src/core/__tests__/Executor.test.js +++ b/packages/jobs/src/core/__tests__/Executor.test.js @@ -1,3 +1,5 @@ +import console from 'node:console' + import { describe, expect, vi, it } from 'vitest' import * as errors from '../../core/errors' @@ -20,7 +22,7 @@ describe('constructor', () => { const options = { adapter: 'adapter', job: 'job' } const exector = new Executor(options) - expect(exector.options).toEqual(options) + expect(exector.options).toEqual(expect.objectContaining(options)) }) it('extracts adapter from options to variable', () => { @@ -37,6 +39,20 @@ describe('constructor', () => { expect(exector.job).toEqual('job') }) + it('extracts logger from options to variable', () => { + const options = { adapter: 'adapter', job: 'job', logger: { foo: 'bar' } } + const exector = new Executor(options) + + expect(exector.logger).toEqual({ foo: 'bar' }) + }) + + it('defaults logger if not provided', () => { + const options = { adapter: 'adapter', job: 'job' } + const exector = new Executor(options) + + expect(exector.logger).toEqual(console) + }) + it('throws AdapterRequiredError if adapter is not provided', () => { const options = { job: 'job' } From 102d76e1ded9b9f4dc97ba9c26f8f13b30c660f6 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 24 Jul 2024 09:01:40 +0200 Subject: [PATCH 106/258] RedwoodJob: TS private syntax --- packages/jobs/src/core/RedwoodJob.ts | 38 +++++++++++++++------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/jobs/src/core/RedwoodJob.ts b/packages/jobs/src/core/RedwoodJob.ts index 39e4ca88a4c2..0ab4b40ed162 100644 --- a/packages/jobs/src/core/RedwoodJob.ts +++ b/packages/jobs/src/core/RedwoodJob.ts @@ -86,7 +86,7 @@ export abstract class RedwoodJob { } // Private property to store options set on the job - #options: JobSetOptions = {} + private myOptions: JobSetOptions = {} // A job can be instantiated manually, but this will also be invoked // automatically by .set() or .performLater() @@ -98,7 +98,7 @@ export abstract class RedwoodJob { // const job = RedwoodJob.set({ wait: 300 }) // job.performLater('foo', 'bar') set(options = {}) { - this.#options = { queue: this.queue, priority: this.priority, ...options } + this.myOptions = { queue: this.queue, priority: this.priority, ...options } return this } @@ -111,7 +111,7 @@ export abstract class RedwoodJob { `[RedwoodJob] Scheduling ${this.constructor.name}`, ) - return this.#schedule(args) + return this.schedule(args) } // Instance method to runs the job immediately in the current process @@ -152,31 +152,33 @@ export abstract class RedwoodJob { get logger() { return ( - this.#options?.logger || (this.constructor as typeof RedwoodJob).logger + this.myOptions?.logger || (this.constructor as typeof RedwoodJob).logger ) } // Determines the name of the queue get queue() { - return this.#options?.queue || (this.constructor as typeof RedwoodJob).queue + return ( + this.myOptions?.queue || (this.constructor as typeof RedwoodJob).queue + ) } // Set the name of the queue directly on an instance of a job set queue(value) { - this.#options = Object.assign(this.#options || {}, { queue: value }) + this.myOptions = Object.assign(this.myOptions || {}, { queue: value }) } // Determines the priority of the job get priority() { return ( - this.#options?.priority || + this.myOptions?.priority || (this.constructor as typeof RedwoodJob).priority ) } // Set the priority of the job directly on an instance of a job set priority(value) { - this.#options = Object.assign(this.#options || {}, { + this.myOptions = Object.assign(this.myOptions || {}, { priority: value, }) } @@ -187,17 +189,17 @@ export abstract class RedwoodJob { // - If a `wait` option is present it sets the number of seconds to wait // - If a `waitUntil` option is present it runs at that specific datetime get runAt() { - if (!this.#options?.runAt) { - this.#options = Object.assign(this.#options || {}, { - runAt: this.#options?.wait - ? new Date(new Date().getTime() + this.#options.wait * 1000) - : this.#options?.waitUntil - ? this.#options.waitUntil + if (!this.myOptions?.runAt) { + this.myOptions = Object.assign(this.myOptions || {}, { + runAt: this.myOptions?.wait + ? new Date(new Date().getTime() + this.myOptions.wait * 1000) + : this.myOptions?.waitUntil + ? this.myOptions.waitUntil : new Date(), }) } - return this.#options.runAt + return this.myOptions.runAt } // Set the runAt time on a job directly: @@ -205,17 +207,17 @@ export abstract class RedwoodJob { // job.runAt = new Date(2030, 1, 2, 12, 34, 56) // job.performLater() set runAt(value) { - this.#options = Object.assign(this.#options || {}, { runAt: value }) + this.myOptions = Object.assign(this.myOptions || {}, { runAt: value }) } // Make private this.#options available as a getter only get options() { - return this.#options + return this.myOptions } // Private, schedules a job with the appropriate adapter, returns whatever // the adapter returns in response to a successful schedule. - #schedule(args: any[]) { + private schedule(args: any[]) { if (!(this.constructor as typeof RedwoodJob).adapter) { throw new AdapterNotConfiguredError() } From a0118d818fecc4d11f09ad4b44d435771818a730 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 24 Jul 2024 09:03:13 +0200 Subject: [PATCH 107/258] RedwoodJob: declare constructor type --- packages/jobs/src/core/RedwoodJob.ts | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/jobs/src/core/RedwoodJob.ts b/packages/jobs/src/core/RedwoodJob.ts index 0ab4b40ed162..9812564c92af 100644 --- a/packages/jobs/src/core/RedwoodJob.ts +++ b/packages/jobs/src/core/RedwoodJob.ts @@ -86,7 +86,9 @@ export abstract class RedwoodJob { } // Private property to store options set on the job - private myOptions: JobSetOptions = {} + private myOptions: JobSetOptions = {}; + + declare ['constructor']: typeof RedwoodJob // A job can be instantiated manually, but this will also be invoked // automatically by .set() or .performLater() @@ -151,16 +153,12 @@ export abstract class RedwoodJob { } get logger() { - return ( - this.myOptions?.logger || (this.constructor as typeof RedwoodJob).logger - ) + return this.myOptions?.logger || this.constructor.logger } // Determines the name of the queue get queue() { - return ( - this.myOptions?.queue || (this.constructor as typeof RedwoodJob).queue - ) + return this.myOptions?.queue || this.constructor.queue } // Set the name of the queue directly on an instance of a job @@ -170,10 +168,7 @@ export abstract class RedwoodJob { // Determines the priority of the job get priority() { - return ( - this.myOptions?.priority || - (this.constructor as typeof RedwoodJob).priority - ) + return this.myOptions?.priority || this.constructor.priority } // Set the priority of the job directly on an instance of a job @@ -218,14 +213,12 @@ export abstract class RedwoodJob { // Private, schedules a job with the appropriate adapter, returns whatever // the adapter returns in response to a successful schedule. private schedule(args: any[]) { - if (!(this.constructor as typeof RedwoodJob).adapter) { + if (!this.constructor.adapter) { throw new AdapterNotConfiguredError() } try { - return (this.constructor as typeof RedwoodJob).adapter.schedule( - this.payload(args), - ) + return this.constructor.adapter.schedule(this.payload(args)) } catch (e: any) { throw new SchedulingError( `[RedwoodJob] Exception when scheduling ${this.constructor.name}`, From 5678061f5a5bf930008066b0e23f8e8e125a3cd0 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 24 Jul 2024 09:16:33 +0200 Subject: [PATCH 108/258] RedwoodJob: Reenable exception tests --- packages/jobs/src/core/RedwoodJob.ts | 3 +-- .../jobs/src/core/__tests__/RedwoodJob.test.js | 17 +++++++++++------ packages/jobs/src/core/errors.ts | 14 -------------- 3 files changed, 12 insertions(+), 22 deletions(-) diff --git a/packages/jobs/src/core/RedwoodJob.ts b/packages/jobs/src/core/RedwoodJob.ts index 9812564c92af..e6966d822710 100644 --- a/packages/jobs/src/core/RedwoodJob.ts +++ b/packages/jobs/src/core/RedwoodJob.ts @@ -18,7 +18,6 @@ import type { BasicLogger } from '../types' import { AdapterNotConfiguredError, - PerformNotImplementedError, SchedulingError, PerformError, } from './errors' @@ -127,7 +126,7 @@ export abstract class RedwoodJob { try { return this.perform(...args) } catch (e: any) { - if (e instanceof PerformNotImplementedError) { + if (e instanceof TypeError) { throw e } else { throw new PerformError( diff --git a/packages/jobs/src/core/__tests__/RedwoodJob.test.js b/packages/jobs/src/core/__tests__/RedwoodJob.test.js index ed42b1ac36c8..83d94d264347 100644 --- a/packages/jobs/src/core/__tests__/RedwoodJob.test.js +++ b/packages/jobs/src/core/__tests__/RedwoodJob.test.js @@ -407,13 +407,18 @@ describe('instance performNow()', () => { vi.clearAllMocks() }) - it.skip('throws an error if perform() function is not implemented', async () => { + it('throws an error if perform() function is not implemented', async () => { class TestJob extends RedwoodJob {} const job = new TestJob() - expect(() => job.performNow('foo', 'bar')).toThrow( - errors.PerformNotImplementedError, - ) + expect(() => job.perform('foo', 'bar')).toThrow(TypeError) + }) + + it('re-throws perform() error from performNow() if perform() function is not implemented', async () => { + class TestJob extends RedwoodJob {} + const job = new TestJob() + + expect(() => job.performNow('foo', 'bar')).toThrow(TypeError) }) it('logs that the job is being run', async () => { @@ -492,10 +497,10 @@ describe('instance performNow()', () => { }) describe('perform()', () => { - it.skip('throws an error if not implemented', () => { + it('throws an error if not implemented', () => { const job = new RedwoodJob() - expect(() => job.perform()).toThrow(errors.PerformNotImplementedError) + expect(() => job.perform()).toThrow(TypeError) }) }) diff --git a/packages/jobs/src/core/errors.ts b/packages/jobs/src/core/errors.ts index 5b9ce211e398..ce7cf9ac7f6a 100644 --- a/packages/jobs/src/core/errors.ts +++ b/packages/jobs/src/core/errors.ts @@ -17,20 +17,6 @@ export class AdapterNotConfiguredError extends RedwoodJobError { } } -// Thrown when trying to schedule a job without a `perform` method -export class PerformNotImplementedError extends RedwoodJobError { - constructor() { - super('You must implement the `perform` method in your job class') - } -} - -// Thrown when a custom adapter does not implement the `schedule` method -export class NotImplementedError extends RedwoodJobError { - constructor(name: string) { - super(`You must implement the \`${name}\` method in your adapter`) - } -} - // Thrown when a given model name isn't actually available in the PrismaClient export class ModelNameError extends RedwoodJobError { constructor(name: string) { From 0e11524c7dcc434e541573b087eab35f7402fdca Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 24 Jul 2024 09:39:16 +0200 Subject: [PATCH 109/258] Adapter TypeScript tests --- packages/jobs/src/adapters/BaseAdapter.ts | 5 +- .../adapters/__tests__/BaseAdapter.test.ts | 40 ++ .../adapters/__tests__/PrismaAdapter.test.ts | 352 ++++++++++++++++++ 3 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 packages/jobs/src/adapters/__tests__/BaseAdapter.test.ts create mode 100644 packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts diff --git a/packages/jobs/src/adapters/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter.ts index 27600ef76640..749911802f2a 100644 --- a/packages/jobs/src/adapters/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter.ts @@ -57,9 +57,12 @@ export abstract class BaseAdapter< // The job engine itself doesn't care about the return value, but the user may // want to do something with the result depending on the adapter type, so make // it `any` to allow for the subclass to return whatever it wants. + abstract schedule(payload: SchedulePayload): any - abstract find(args: FindArgs): BaseJob | null | Promise + abstract find( + args: FindArgs, + ): BaseJob | null | undefined | Promise // TODO accept an optional `queue` arg to clear only jobs in that queue abstract clear(): any diff --git a/packages/jobs/src/adapters/__tests__/BaseAdapter.test.ts b/packages/jobs/src/adapters/__tests__/BaseAdapter.test.ts new file mode 100644 index 000000000000..28518dc36f20 --- /dev/null +++ b/packages/jobs/src/adapters/__tests__/BaseAdapter.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, vi, it } from 'vitest' + +import { BaseAdapter } from '../BaseAdapter' +import type { BaseAdapterOptions } from '../BaseAdapter' + +const mockLogger = { + log: vi.fn(() => {}), + info: vi.fn(() => {}), + debug: vi.fn(() => {}), + warn: vi.fn(() => {}), + error: vi.fn(() => {}), +} + +interface TestAdapterOptions extends BaseAdapterOptions { + foo: string +} + +class TestAdapter extends BaseAdapter { + schedule() {} + clear() {} + success() {} + failure() {} + find() { + return undefined + } +} + +describe('constructor', () => { + it('saves options', () => { + const adapter = new TestAdapter({ foo: 'bar' }) + + expect(adapter.options.foo).toEqual('bar') + }) + + it('creates a separate instance var for any logger', () => { + const adapter = new TestAdapter({ foo: 'bar', logger: mockLogger }) + + expect(adapter.logger).toEqual(mockLogger) + }) +}) diff --git a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts new file mode 100644 index 000000000000..025a3e21f07c --- /dev/null +++ b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts @@ -0,0 +1,352 @@ +import type { PrismaClient } from '@prisma/client' +import { describe, expect, vi, it, beforeEach, afterEach } from 'vitest' + +import type CliHelpers from '@redwoodjs/cli-helpers' + +import * as errors from '../../core/errors' +import { PrismaAdapter, DEFAULTS } from '../PrismaAdapter' + +vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) + +vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { + const originalCliHelpers = await importOriginal() + + return { + ...originalCliHelpers, + isTypeScriptProject: () => false, + } +}) + +let mockDb: PrismaClient + +beforeEach(() => { + mockDb = { + _activeProvider: 'sqlite', + _runtimeDataModel: { + models: { + BackgroundJob: { + dbName: null, + }, + }, + }, + backgroundJob: { + create: vi.fn(), + delete: vi.fn(), + deleteMany: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + } +}) + +afterEach(() => { + vi.resetAllMocks() +}) + +describe('constructor', () => { + it('defaults this.model name', () => { + const adapter = new PrismaAdapter({ db: mockDb }) + + expect(adapter.model).toEqual(DEFAULTS.model) + }) + + it('can manually set this.model', () => { + mockDb._runtimeDataModel.models = { + Job: { + dbName: null, + }, + } + mockDb.job = {} + + const adapter = new PrismaAdapter({ + db: mockDb, + model: 'Job', + }) + + expect(adapter.model).toEqual('Job') + }) + + it('throws an error with a model name that does not exist', () => { + expect(() => new PrismaAdapter({ db: mockDb, model: 'FooBar' })).toThrow( + errors.ModelNameError, + ) + }) + + it('sets this.accessor to the correct Prisma accessor', () => { + const adapter = new PrismaAdapter({ db: mockDb }) + + expect(adapter.accessor).toEqual(mockDb.backgroundJob) + }) + + it('sets this.provider based on the active provider', () => { + const adapter = new PrismaAdapter({ db: mockDb }) + + expect(adapter.provider).toEqual('sqlite') + }) +}) + +describe('schedule()', () => { + it('creates a job in the DB with required data', async () => { + const createSpy = vi + .spyOn(mockDb.backgroundJob, 'create') + .mockReturnValue({ id: 1 }) + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.schedule({ + handler: 'RedwoodJob', + args: ['foo', 'bar'], + queue: 'default', + priority: 50, + runAt: new Date(), + }) + + expect(createSpy).toHaveBeenCalledWith({ + data: { + handler: JSON.stringify({ + handler: 'RedwoodJob', + args: ['foo', 'bar'], + }), + priority: 50, + queue: 'default', + runAt: new Date(), + }, + }) + }) +}) + +describe('find()', () => { + it('returns null if no job found', async () => { + vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(null) + const adapter = new PrismaAdapter({ db: mockDb }) + const job = await adapter.find({ + processName: 'test', + maxRuntime: 1000, + queue: 'foobar', + }) + + expect(job).toBeNull() + }) + + it('returns a job if found', async () => { + const mockJob = { id: 1 } + vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) + vi.spyOn(mockDb.backgroundJob, 'updateMany').mockReturnValue({ count: 1 }) + const adapter = new PrismaAdapter({ db: mockDb }) + const job = await adapter.find({ + processName: 'test', + maxRuntime: 1000, + queue: 'default', + }) + + expect(job).toEqual(mockJob) + }) + + it('increments the `attempts` count on the found job', async () => { + const mockJob = { id: 1, attempts: 0 } + vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) + const updateSpy = vi + .spyOn(mockDb.backgroundJob, 'updateMany') + .mockReturnValue({ count: 1 }) + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.find({ + processName: 'test', + maxRuntime: 1000, + queue: 'default', + }) + + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ attempts: 1 }), + }), + ) + }) + + it('locks the job for the current process', async () => { + const mockJob = { id: 1, attempts: 0 } + vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) + const updateSpy = vi + .spyOn(mockDb.backgroundJob, 'updateMany') + .mockReturnValue({ count: 1 }) + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.find({ + processName: 'test-process', + maxRuntime: 1000, + queue: 'default', + }) + + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ lockedBy: 'test-process' }), + }), + ) + }) + + it('locks the job with a current timestamp', async () => { + const mockJob = { id: 1, attempts: 0 } + vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) + const updateSpy = vi + .spyOn(mockDb.backgroundJob, 'updateMany') + .mockReturnValue({ count: 1 }) + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.find({ + processName: 'test-process', + maxRuntime: 1000, + queue: 'default', + }) + + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ lockedAt: new Date() }), + }), + ) + }) +}) + +const mockPrismaJob = { + id: 1, + handler: '', + args: undefined, + attempts: 10, + runAt: new Date(), + lockedAt: new Date(), + lockedBy: 'test-process', + lastError: null, + failedAt: null, + createdAt: new Date(), + updatedAt: new Date(), +} + +describe('success()', () => { + it('deletes the job from the DB', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'delete') + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.success(mockPrismaJob) + + expect(spy).toHaveBeenCalledWith({ where: { id: 1 } }) + }) +}) + +describe('failure()', () => { + it('updates the job by id', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'update') + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.failure(mockPrismaJob, new Error('test error')) + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 1 } }), + ) + }) + + it('clears the lock fields', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'update') + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.failure(mockPrismaJob, new Error('test error')) + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ lockedAt: null, lockedBy: null }), + }), + ) + }) + + it('reschedules the job at a designated backoff time', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'update') + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.failure(mockPrismaJob, new Error('test error')) + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + runAt: new Date(new Date().getTime() + 1000 * 10 ** 4), + }), + }), + ) + }) + + it('records the error', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'update') + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.failure(mockPrismaJob, new Error('test error')) + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + lastError: expect.stringContaining('test error'), + }), + }), + ) + }) + + it('nullifies runtAt if max attempts reached', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'update') + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.failure(mockPrismaJob, new Error('test error'), { + maxAttempts: 10, + }) + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + runAt: null, + }), + }), + ) + }) + + it('marks the job as failed if max attempts reached', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'update') + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.failure(mockPrismaJob, new Error('test error'), { + maxAttempts: 10, + deleteFailedJobs: false, + }) + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + failedAt: new Date(), + }), + }), + ) + }) + + it('deletes the job if max attempts reached and deleteFailedJobs set to true', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'delete') + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.failure(mockPrismaJob, new Error('test error'), { + maxAttempts: 10, + deleteFailedJobs: true, + }) + + expect(spy).toHaveBeenCalledWith({ where: { id: 1 } }) + }) +}) + +describe('clear()', () => { + it('deletes all jobs from the DB', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'deleteMany') + + const adapter = new PrismaAdapter({ db: mockDb }) + await adapter.clear() + + expect(spy).toHaveBeenCalledOnce() + }) +}) + +describe('backoffMilliseconds()', () => { + it('returns the number of milliseconds to wait for the next run', () => { + expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(0)).toEqual(0) + expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(1)).toEqual( + 1000, + ) + expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(2)).toEqual( + 16000, + ) + expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(3)).toEqual( + 81000, + ) + expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(20)).toEqual( + 160000000, + ) + }) +}) From 035ef23a5774fee6fbfe6d2a27829aa7bc297862 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Fri, 12 Jul 2024 10:10:33 +0200 Subject: [PATCH 110/258] RedwoodJob.ts: Fix comment after myOptions rename --- packages/jobs/src/core/RedwoodJob.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jobs/src/core/RedwoodJob.ts b/packages/jobs/src/core/RedwoodJob.ts index e6966d822710..3a7cda18bd0b 100644 --- a/packages/jobs/src/core/RedwoodJob.ts +++ b/packages/jobs/src/core/RedwoodJob.ts @@ -204,7 +204,7 @@ export abstract class RedwoodJob { this.myOptions = Object.assign(this.myOptions || {}, { runAt: value }) } - // Make private this.#options available as a getter only + // Make private this.myOptions available as a getter only get options() { return this.myOptions } From e1ef77a86a9a819857a77eda0f67a51556292809 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 24 Jul 2024 10:19:26 +0200 Subject: [PATCH 111/258] RedwoodJob TS test --- .../src/core/__tests__/RedwoodJob.test.ts | 517 ++++++++++++++++++ 1 file changed, 517 insertions(+) create mode 100644 packages/jobs/src/core/__tests__/RedwoodJob.test.ts diff --git a/packages/jobs/src/core/__tests__/RedwoodJob.test.ts b/packages/jobs/src/core/__tests__/RedwoodJob.test.ts new file mode 100644 index 000000000000..127656a91e8d --- /dev/null +++ b/packages/jobs/src/core/__tests__/RedwoodJob.test.ts @@ -0,0 +1,517 @@ +import { describe, expect, vi, it, beforeEach } from 'vitest' + +import type CliHelpers from '@redwoodjs/cli-helpers' + +import * as errors from '../errors' +import { RedwoodJob } from '../RedwoodJob' + +vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { + const originalCliHelpers = await importOriginal() + + return { + ...originalCliHelpers, + isTypeScriptProject: () => true, + } +}) + +vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) + +const mockLogger = { + log: vi.fn(() => {}), + info: vi.fn(() => {}), + debug: vi.fn(() => {}), + warn: vi.fn(() => {}), + error: vi.fn(() => {}), +} + +const mockAdapter = { + options: {}, + logger: mockLogger, + schedule: vi.fn(() => {}), + find: () => null, + clear: () => {}, + success: (_job: { handler: string; args: any }) => {}, + failure: (_job: { handler: string; args: any }, _error: Error) => {}, +} + +const jobConfig = { + adapter: mockAdapter, + logger: mockLogger, +} + +class TestJob extends RedwoodJob { + async perform() { + return 'done' + } +} + +describe('static config', () => { + it('can set the adapter', () => { + RedwoodJob.config(jobConfig) + + expect(RedwoodJob.adapter).toEqual(jobConfig.adapter) + }) + + it('can set the logger', () => { + RedwoodJob.config(jobConfig) + + expect(RedwoodJob.logger).toEqual(jobConfig.logger) + }) +}) + +describe('constructor()', () => { + it('returns an instance of the job', () => { + const job = new TestJob() + expect(job).toBeInstanceOf(RedwoodJob) + }) + + it('defaults some options', () => { + const job = new TestJob() + expect(job.options).toEqual({ + queue: RedwoodJob.queue, + priority: RedwoodJob.priority, + }) + }) + + it('can set options for the job', () => { + const job = new TestJob({ wait: 5 }) + expect(job.options.wait).toEqual(5) + }) +}) + +describe('static set()', () => { + it('returns a job instance', () => { + const job = TestJob.set({ wait: 300 }) + + expect(job).toBeInstanceOf(TestJob) + }) + + it('sets options for the job', () => { + const job = TestJob.set({ runAt: new Date(700) }) + + expect(job.options.runAt?.getTime()).toEqual(new Date(700).getTime()) + }) + + it('sets the default queue', () => { + const job = TestJob.set({ priority: 3 }) + + expect(job.options.queue).toEqual(TestJob.queue) + }) + + it('sets the default priority', () => { + const job = TestJob.set({ queue: 'bar' }) + + expect(job.options.priority).toEqual(TestJob.priority) + }) + + it('can override the queue name set in the class', () => { + const job = TestJob.set({ priority: 5, queue: 'priority' }) + + expect(job.options.queue).toEqual('priority') + }) + + it('can override the priority set in the class', () => { + const job = TestJob.set({ queue: 'bar', priority: 10 }) + + expect(job.options.priority).toEqual(10) + }) +}) + +describe('instance set()', () => { + it('returns a job instance', () => { + const job = new TestJob().set({ wait: 300 }) + + expect(job).toBeInstanceOf(TestJob) + }) + + it('sets options for the job', () => { + const job = new TestJob().set({ runAt: new Date(700) }) + + expect(job.options.runAt?.getTime()).toEqual(new Date(700).getTime()) + }) + + it('sets the default queue', () => { + const job = new TestJob().set({ foo: 'bar' }) + + expect(job.options.queue).toEqual(TestJob.queue) + }) + + it('sets the default priority', () => { + const job = new TestJob().set({ foo: 'bar' }) + + expect(job.options.priority).toEqual(TestJob.priority) + }) + + it('can override the queue name set in the class', () => { + const job = new TestJob().set({ foo: 'bar', queue: 'priority' }) + + expect(job.options.queue).toEqual('priority') + }) + + it('can override the priority set in the class', () => { + const job = new TestJob().set({ foo: 'bar', priority: 10 }) + + expect(job.options.priority).toEqual(10) + }) +}) + +describe('get runAt()', () => { + it('returns the current time if no options are set', () => { + const job = new TestJob() + + expect(job.runAt).toEqual(new Date()) + }) + + it('returns a datetime `wait` seconds in the future if option set', async () => { + const job = TestJob.set({ wait: 300 }) + + expect(job.runAt).toEqual(new Date(Date.UTC(2024, 0, 1, 0, 5, 0))) + }) + + it('returns a datetime set to `waitUntil` if option set', async () => { + const futureDate = new Date(2030, 1, 2, 12, 34, 56) + const job = TestJob.set({ + waitUntil: futureDate, + }) + + expect(job.runAt).toEqual(futureDate) + }) + + it('returns any datetime set directly on the instance', () => { + const futureDate = new Date(2030, 1, 2, 12, 34, 56) + const job = new TestJob() + job.runAt = futureDate + + expect(job.runAt).toEqual(futureDate) + }) + + it('sets the computed time in the `options` property', () => { + const job = new TestJob() + const runAt = job.runAt + + expect(job.options.runAt).toEqual(runAt) + }) +}) + +describe('set runAt()', () => { + it('allows manually setting runAt time directly on the instance', () => { + const futureDate = new Date(2030, 1, 2, 12, 34, 56) + const job = new TestJob() + job.runAt = futureDate + + expect(job.runAt).toEqual(futureDate) + }) + + it('sets the `options.runAt` property', () => { + const futureDate = new Date(2030, 1, 2, 12, 34, 56) + const job = new TestJob() + job.runAt = futureDate + + expect(job.options.runAt).toEqual(futureDate) + }) +}) + +describe('get queue()', () => { + it('defaults to queue set in class', () => { + const job = new TestJob() + + expect(job.queue).toEqual(TestJob.queue) + }) + + it('allows manually setting the queue name on an instance', () => { + const job = new TestJob() + job.queue = 'priority' + + expect(job.queue).toEqual('priority') + }) + + it('prefers the queue set manually over queue set as an option', () => { + const job = TestJob.set({ queue: 'priority' }) + job.queue = 'important' + + expect(job.queue).toEqual('important') + }) +}) + +describe('set queue()', () => { + it('sets the queue name in `options.queue`', () => { + const job = new TestJob() + job.queue = 'priority' + + expect(job.options.queue).toEqual('priority') + }) +}) + +describe('get priority()', () => { + it('defaults to priority set in class', () => { + const job = new TestJob() + + expect(job.priority).toEqual(TestJob.priority) + }) + + it('allows manually setting the priority name on an instance', () => { + const job = new TestJob() + job.priority = 10 + + expect(job.priority).toEqual(10) + }) + + it('prefers priority set manually over priority set as an option', () => { + const job = TestJob.set({ priority: 20 }) + job.priority = 10 + + expect(job.priority).toEqual(10) + }) +}) + +describe('set priority()', () => { + it('sets the priority in `options.priority`', () => { + const job = new TestJob() + job.priority = 10 + + expect(job.options.priority).toEqual(10) + }) +}) + +describe('static performLater()', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('invokes the instance performLater()', () => { + const spy = vi.spyOn(TestJob.prototype, 'performLater') + RedwoodJob.config({ adapter: mockAdapter }) + + TestJob.performLater('foo', 'bar') + + expect(spy).toHaveBeenCalledWith('foo', 'bar') + }) +}) + +describe('instance performLater()', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('throws an error if no adapter is configured', async () => { + // @ts-expect-error - testing JS scenario + TestJob.config({ adapter: undefined }) + + const job = new TestJob() + + expect(() => job.performLater('foo', 'bar')).toThrow( + errors.AdapterNotConfiguredError, + ) + }) + + it('logs that the job is being scheduled', async () => { + TestJob.config({ adapter: mockAdapter, logger: mockLogger }) + + await new TestJob().performLater('foo', 'bar') + + expect(mockLogger.info).toHaveBeenCalledWith( + { + args: ['foo', 'bar'], + handler: 'TestJob', + priority: 50, + queue: 'default', + runAt: new Date(), + }, + '[RedwoodJob] Scheduling TestJob', + ) + }) + + it('calls the `schedule` function on the adapter', async () => { + RedwoodJob.config({ adapter: mockAdapter }) + + await new TestJob().performLater('foo', 'bar') + + expect(mockAdapter.schedule).toHaveBeenCalledWith({ + handler: 'TestJob', + args: ['foo', 'bar'], + queue: 'default', + priority: 50, + runAt: new Date(), + }) + }) + + it('returns whatever the adapter returns', async () => { + const scheduleReturn = { status: 'scheduled' } + const adapter = { + ...mockAdapter, + schedule: vi.fn(() => scheduleReturn), + } + TestJob.config({ adapter }) + + const result = await new TestJob().performLater('foo', 'bar') + + expect(result).toEqual(scheduleReturn) + }) + + it('catches any errors thrown during schedulding and throws custom error', async () => { + const adapter = { + ...mockAdapter, + schedule: vi.fn(() => { + throw new Error('Could not schedule') + }), + } + RedwoodJob.config({ adapter }) + + try { + await new TestJob().performLater('foo', 'bar') + } catch (e) { + expect(e).toBeInstanceOf(errors.SchedulingError) + expect(e.message).toEqual( + '[RedwoodJob] Exception when scheduling TestJob', + ) + expect(e.originalError.message).toEqual('Could not schedule') + } + }) +}) + +describe('static performNow()', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('invokes the instance performNow()', () => { + const spy = vi.spyOn(TestJob.prototype, 'performNow') + RedwoodJob.config({ adapter: mockAdapter }) + + TestJob.performNow('foo', 'bar') + + expect(spy).toHaveBeenCalledWith('foo', 'bar') + }) +}) + +describe('instance performNow()', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('throws an error if perform() function is not implemented', async () => { + // @ts-expect-error - testing JS scenario + class TestJob extends RedwoodJob {} + const job = new TestJob() + + expect(() => job.perform('foo', 'bar')).toThrow(TypeError) + }) + + it('re-throws perform() error from performNow() if perform() function is not implemented', async () => { + // @ts-expect-error - testing JS scenario + class TestJob extends RedwoodJob {} + const job = new TestJob() + + expect(() => job.performNow('foo', 'bar')).toThrow(TypeError) + }) + + it('logs that the job is being run', async () => { + TestJob.config({ adapter: mockAdapter, logger: mockLogger }) + + await new TestJob().performNow('foo', 'bar') + + expect(mockLogger.info).toHaveBeenCalledWith( + { + args: ['foo', 'bar'], + handler: 'TestJob', + priority: 50, + queue: 'default', + runAt: new Date(), + }, + '[RedwoodJob] Running TestJob now', + ) + }) + + it('invokes the perform() function immediately', async () => { + const spy = vi.spyOn(TestJob.prototype, 'perform') + + await new TestJob().performNow('foo', 'bar') + + expect(spy).toHaveBeenCalledWith('foo', 'bar') + }) + + it('returns whatever the perform() function returns', async () => { + const performReturn = { status: 'done' } + class TestJob extends RedwoodJob { + async perform() { + return performReturn + } + } + + const result = await new TestJob().performNow('foo', 'bar') + + expect(result).toEqual(performReturn) + }) + + it('catches any errors thrown during perform and throws custom error', async () => { + class TestJobPerf extends RedwoodJob { + perform() { + throw new Error('Could not perform') + } + } + const adapter = { + ...mockAdapter, + schedule: vi.fn(() => { + throw new Error('Could not schedule') + }), + } + + RedwoodJob.config({ adapter }) + + try { + new TestJobPerf().performNow('foo', 'bar') + } catch (e) { + expect(e).toBeInstanceOf(errors.PerformError) + expect(e.message).toEqual('[TestJobPerf] exception when running job') + expect(e.originalError.message).toEqual('Could not perform') + } + }) +}) + +describe('perform()', () => { + it('throws an error if not implemented', () => { + // @ts-expect-error - testing JS scenario + const job = new RedwoodJob() + + expect(() => job.perform()).toThrow(TypeError) + }) +}) + +describe('subclasses', () => { + it('can set its own queue', () => { + class MailerJob extends RedwoodJob { + static queue = 'mailers' + + perform() { + return 'done' + } + } + + // class access + expect(MailerJob.queue).toEqual('mailers') + expect(RedwoodJob.queue).toEqual('default') + + // instance access (not including RedwoodJob here, becuase it can't be + // instantiated since it's abstract) + const mailerJob = new MailerJob() + expect(mailerJob.queue).toEqual('mailers') + }) + + it('can set its own priority', () => { + class PriorityJob extends RedwoodJob { + static priority = 10 + + perform() { + return 'done' + } + } + + // class access + expect(PriorityJob.priority).toEqual(10) + expect(RedwoodJob.priority).toEqual(50) + + // instance access (again, not testing RedwoodJob. See comment above) + const priorityJob = new PriorityJob() + expect(priorityJob.priority).toEqual(10) + }) +}) From e40715ade7fda186032f8f08202a6943ac5e2ef6 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 23 Jul 2024 17:03:07 -0700 Subject: [PATCH 112/258] Check for logger and fall back to console --- packages/jobs/src/bins/rw-jobs.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index e506ec01b6f6..09a68f462f22 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -266,7 +266,16 @@ const clearQueue = ({ logger }: { logger: BasicLogger }) => { const main = async () => { const { workerDef, command } = parseArgs(process.argv) const workerConfig = buildWorkerConfig(workerDef) - const { logger } = await loadWorkerConfig() + + // user may have defined a custom logger, so use that if it exists + const libWorkerConfig = await loadWorkerConfig() + let logger + + if (libWorkerConfig.logger) { + logger = libWorkerConfig.logger + } else { + logger = console + } logger.warn(`Starting RedwoodJob Runner at ${new Date().toISOString()}...`) From 679c387ad52b4ef1cc2b60b5005dfb2a15d07eda Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 24 Jul 2024 14:27:53 -0700 Subject: [PATCH 113/258] Mock console to quiet down test output --- .../jobs/src/core/__tests__/RedwoodJob.test.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/jobs/src/core/__tests__/RedwoodJob.test.js b/packages/jobs/src/core/__tests__/RedwoodJob.test.js index 83d94d264347..59bb47e84c59 100644 --- a/packages/jobs/src/core/__tests__/RedwoodJob.test.js +++ b/packages/jobs/src/core/__tests__/RedwoodJob.test.js @@ -1,4 +1,4 @@ -import { describe, expect, vi, it, beforeEach } from 'vitest' +import { describe, expect, vi, it, beforeEach, afterEach } from 'vitest' import * as errors from '../../core/errors' import { RedwoodJob } from '../RedwoodJob' @@ -259,7 +259,11 @@ describe('set priority()', () => { describe('static performLater()', () => { beforeEach(() => { - vi.clearAllMocks() + vi.spyOn(console, 'info').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.resetAllMocks() }) it('invokes the instance performLater()', () => { @@ -280,7 +284,11 @@ describe('static performLater()', () => { describe('instance performLater()', () => { beforeEach(() => { - vi.clearAllMocks() + vi.spyOn(console, 'info').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.resetAllMocks() }) it('throws an error if no adapter is configured', async () => { From a952c52c8aa6b23c133b1ce5eaa59c15185edcd1 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 24 Jul 2024 14:28:01 -0700 Subject: [PATCH 114/258] Update jobs setup template --- .../setup/jobs/templates/jobs.ts.template | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template b/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template index 3c5a50c0d01e..2f1a05331529 100644 --- a/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template +++ b/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template @@ -3,16 +3,29 @@ // See https://docs.redwoodjs.com/docs/background-jobs import { PrismaAdapter, RedwoodJob } from '@redwoodjs/jobs' +import type { AvailableJobs, WorkerConfig } from '@redwoodjs/jobs' import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' -// Must export `adapter` so that the job runner can use it to look for jobs -export const adapter = new PrismaAdapter({ db, logger }) +const adapter = new PrismaAdapter({ db, logger }) // Global config for all jobs, can override on a per-job basis RedwoodJob.config({ adapter, logger }) +// Global config for all workers, can override on a per-worker basis +// see https://docs.redwoodjs.com/docs/background-jobs#worker-configuration +export const workerConfig: WorkerConfig = { + adapter, + logger, + maxAttempts: 24, + maxRuntime: 14_400, // 4 hours + sleepDelay: 5, + deleteFailedJobs: false, +} + // Export instances of all your jobs to make them easy to import and use: // export const jobs = { sample: new SampleJob(), email: new EmailJob() } -export const jobs = {} +export const jobs: AvailableJobs = { + foo: new TestJob(), +} From b54d7534f62e8785792eeac1f8d6e8050a0b0a21 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 24 Jul 2024 14:28:39 -0700 Subject: [PATCH 115/258] Better options types for Worker and Executor, export a couple of types for use in jobs config --- packages/jobs/src/core/Executor.ts | 24 ++++++++++++--------- packages/jobs/src/core/Worker.ts | 34 ++++++++++++++++++------------ packages/jobs/src/core/consts.ts | 4 ++++ packages/jobs/src/index.ts | 7 ++++-- packages/jobs/src/types.ts | 8 +++++++ 5 files changed, 52 insertions(+), 25 deletions(-) create mode 100644 packages/jobs/src/core/consts.ts diff --git a/packages/jobs/src/core/Executor.ts b/packages/jobs/src/core/Executor.ts index eb52fc459230..e221b5e46c70 100644 --- a/packages/jobs/src/core/Executor.ts +++ b/packages/jobs/src/core/Executor.ts @@ -5,42 +5,46 @@ import console from 'node:console' import type { BaseAdapter } from '../adapters/BaseAdapter' import type { BasicLogger } from '../types' +import { DEFAULT_MAX_ATTEMPTS, DEFAULT_DELETE_FAILED_JOBS } from './consts' import { AdapterRequiredError, JobRequiredError, JobExportNotFoundError, } from './errors' import { loadJob } from './loaders' -import { DEFAULT_MAX_ATTEMPTS, DEFAULT_DELETE_FAILED_JOBS } from './Worker' -interface ExecutorOptions { +interface Options { adapter: BaseAdapter - logger?: BasicLogger job: any - maxAttempts: number - deleteFailedJobs: boolean + logger?: BasicLogger + maxAttempts?: number + deleteFailedJobs?: boolean } -interface ExecutorOptionsWithDefaults extends ExecutorOptions { +interface DefaultOptions { logger: BasicLogger + maxAttempts: number + deleteFailedJobs: boolean } -export const DEFAULTS = { +type CompleteOptions = Options & DefaultOptions + +export const DEFAULTS: DefaultOptions = { logger: console, maxAttempts: DEFAULT_MAX_ATTEMPTS, deleteFailedJobs: DEFAULT_DELETE_FAILED_JOBS, } export class Executor { - options: ExecutorOptionsWithDefaults + options: CompleteOptions adapter: BaseAdapter logger: BasicLogger job: any | null maxAttempts: number deleteFailedJobs: boolean - constructor(options: ExecutorOptions) { - this.options = { ...DEFAULTS, ...options } as ExecutorOptionsWithDefaults + constructor(options: Options) { + this.options = { ...DEFAULTS, ...options } // validate that everything we need is available if (!this.options.adapter) { diff --git a/packages/jobs/src/core/Worker.ts b/packages/jobs/src/core/Worker.ts index 67c952a372e4..6f835f15dbcc 100644 --- a/packages/jobs/src/core/Worker.ts +++ b/packages/jobs/src/core/Worker.ts @@ -7,25 +7,37 @@ import { setTimeout } from 'node:timers' import type { BaseAdapter } from '../adapters/BaseAdapter' import type { BasicLogger } from '../types' +import { + DEFAULT_MAX_ATTEMPTS, + DEFAULT_MAX_RUNTIME, + DEFAULT_SLEEP_DELAY, + DEFAULT_DELETE_FAILED_JOBS, +} from './consts' import { AdapterRequiredError } from './errors' import { Executor } from './Executor' -interface WorkerOptions { +// The options set in api/src/lib/jobs.ts +export interface WorkerConfig { adapter: BaseAdapter logger?: BasicLogger maxAttempts?: number maxRuntime?: number deleteFailedJobs?: boolean sleepDelay?: number +} + +// Additional options that the rw-jobs-worker process will set when +// instantiatng the Worker class +interface Options { clear?: boolean processName?: string queue?: string | null - waitTime?: number forever?: boolean workoff?: boolean } -interface WorkerOptionsWithDefaults extends WorkerOptions { +// The default options to be used if any of the above are not set +interface DefaultOptions { logger: BasicLogger maxAttempts: number maxRuntime: number @@ -34,29 +46,25 @@ interface WorkerOptionsWithDefaults extends WorkerOptions { clear: boolean processName: string queue: string | null - waitTime: number forever: boolean workoff: boolean } -export const DEFAULT_MAX_ATTEMPTS = 24 -export const DEFAULT_DELETE_FAILED_JOBS = false - -export const DEFAULTS = { +export const DEFAULTS: DefaultOptions = { logger: console, processName: process.title, queue: null, clear: false, maxAttempts: DEFAULT_MAX_ATTEMPTS, - maxRuntime: 14_400, // 4 hours in seconds - sleepDelay: 5, // 5 seconds + maxRuntime: DEFAULT_MAX_RUNTIME, // 4 hours in seconds + sleepDelay: DEFAULT_SLEEP_DELAY, // 5 seconds deleteFailedJobs: DEFAULT_DELETE_FAILED_JOBS, forever: true, workoff: false, } export class Worker { - options: WorkerOptionsWithDefaults + options: WorkerConfig & Options & DefaultOptions adapter: BaseAdapter logger: BasicLogger clear: boolean @@ -70,8 +78,8 @@ export class Worker { forever: boolean workoff: boolean - constructor(options: WorkerOptions) { - this.options = { ...DEFAULTS, ...options } as WorkerOptionsWithDefaults + constructor(options: WorkerConfig & Options) { + this.options = { ...DEFAULTS, ...options } if (!options?.adapter) { throw new AdapterRequiredError() diff --git a/packages/jobs/src/core/consts.ts b/packages/jobs/src/core/consts.ts new file mode 100644 index 000000000000..f71eeda35f46 --- /dev/null +++ b/packages/jobs/src/core/consts.ts @@ -0,0 +1,4 @@ +export const DEFAULT_MAX_ATTEMPTS = 24 +export const DEFAULT_MAX_RUNTIME = 14_400 // 4 hours in seconds +export const DEFAULT_SLEEP_DELAY = 5 // 5 seconds +export const DEFAULT_DELETE_FAILED_JOBS = false diff --git a/packages/jobs/src/index.ts b/packages/jobs/src/index.ts index 54149c361b60..111d27e90ac7 100644 --- a/packages/jobs/src/index.ts +++ b/packages/jobs/src/index.ts @@ -1,8 +1,11 @@ +export * from './core/errors' + export { RedwoodJob } from './core/RedwoodJob' export { Executor } from './core/Executor' export { Worker } from './core/Worker' -export * from './core/errors' - export { BaseAdapter } from './adapters/BaseAdapter' export { PrismaAdapter } from './adapters/PrismaAdapter' + +export type { WorkerConfig } from './core/Worker' +export type { AvailableJobs } from './types' diff --git a/packages/jobs/src/types.ts b/packages/jobs/src/types.ts index a2edcdb1a55f..d8663411f0b3 100644 --- a/packages/jobs/src/types.ts +++ b/packages/jobs/src/types.ts @@ -1,3 +1,5 @@ +import type { RedwoodJob } from './core/RedwoodJob' + // Defines the basic shape of a logger that RedwoodJob will invoke to print // debug messages. RedwoodJob will fallback to use `console` if no // logger is passed in to RedwoodJob or any adapter. Luckily both Redwood's @@ -8,3 +10,9 @@ export interface BasicLogger { warn: (message?: any, ...optionalParams: any[]) => void error: (message?: any, ...optionalParams: any[]) => void } + +// The type of the `jobs` object that's exported from api/src/lib/jobs.ts that +// contains an instance of all jobs that can be run +export interface AvailableJobs { + [key: string]: RedwoodJob +} From fba419c0fc87d8ca40eca036b01be719a47a7c8d Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 24 Jul 2024 14:42:19 -0700 Subject: [PATCH 116/258] Remove .js test files --- .../adapters/__tests__/BaseAdapter.test.js | 18 - .../adapters/__tests__/PrismaAdapter.test.js | 335 ----------- .../src/core/__tests__/RedwoodJob.test.js | 547 ------------------ 3 files changed, 900 deletions(-) delete mode 100644 packages/jobs/src/adapters/__tests__/BaseAdapter.test.js delete mode 100644 packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js delete mode 100644 packages/jobs/src/core/__tests__/RedwoodJob.test.js diff --git a/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js b/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js deleted file mode 100644 index 4f92c194e718..000000000000 --- a/packages/jobs/src/adapters/__tests__/BaseAdapter.test.js +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, vi, it } from 'vitest' - -import { BaseAdapter } from '../BaseAdapter' - -describe('constructor', () => { - it('saves options', () => { - const adapter = new BaseAdapter({ foo: 'bar' }) - - expect(adapter.options.foo).toEqual('bar') - }) - - it('creates a separate instance var for any logger', () => { - const mockLogger = vi.fn() - const adapter = new BaseAdapter({ foo: 'bar', logger: mockLogger }) - - expect(adapter.logger).toEqual(mockLogger) - }) -}) diff --git a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js deleted file mode 100644 index d81e5cd411dc..000000000000 --- a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.js +++ /dev/null @@ -1,335 +0,0 @@ -import { describe, expect, vi, it, beforeEach, afterEach } from 'vitest' - -import * as errors from '../../core/errors' -import { PrismaAdapter, DEFAULTS } from '../PrismaAdapter' - -vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) - -vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { - const originalCliHelpers = await importOriginal() - - return { - ...originalCliHelpers, - isTypeScriptProject: () => false, - } -}) - -let mockDb - -beforeEach(() => { - mockDb = { - _activeProvider: 'sqlite', - _runtimeDataModel: { - models: { - BackgroundJob: { - dbName: null, - }, - }, - }, - backgroundJob: { - create: vi.fn(), - delete: vi.fn(), - deleteMany: vi.fn(), - findFirst: vi.fn(), - update: vi.fn(), - updateMany: vi.fn(), - }, - } -}) - -afterEach(() => { - vi.resetAllMocks() -}) - -describe('constructor', () => { - it('defaults this.model name', () => { - const adapter = new PrismaAdapter({ db: mockDb }) - - expect(adapter.model).toEqual(DEFAULTS.model) - }) - - it('can manually set this.model', () => { - mockDb._runtimeDataModel.models = { - Job: { - dbName: null, - }, - } - mockDb.job = {} - - const adapter = new PrismaAdapter({ - db: mockDb, - model: 'Job', - }) - - expect(adapter.model).toEqual('Job') - }) - - it('throws an error with a model name that does not exist', () => { - expect(() => new PrismaAdapter({ db: mockDb, model: 'FooBar' })).toThrow( - errors.ModelNameError, - ) - }) - - it('sets this.accessor to the correct Prisma accessor', () => { - const adapter = new PrismaAdapter({ db: mockDb }) - - expect(adapter.accessor).toEqual(mockDb.backgroundJob) - }) - - it('sets this.provider based on the active provider', () => { - const adapter = new PrismaAdapter({ db: mockDb }) - - expect(adapter.provider).toEqual('sqlite') - }) -}) - -describe('schedule()', () => { - it('creates a job in the DB with required data', async () => { - const createSpy = vi - .spyOn(mockDb.backgroundJob, 'create') - .mockReturnValue({ id: 1 }) - const adapter = new PrismaAdapter({ db: mockDb }) - await adapter.schedule({ - handler: 'RedwoodJob', - args: ['foo', 'bar'], - queue: 'default', - priority: 50, - runAt: new Date(), - }) - - expect(createSpy).toHaveBeenCalledWith({ - data: { - handler: JSON.stringify({ - handler: 'RedwoodJob', - args: ['foo', 'bar'], - }), - priority: 50, - queue: 'default', - runAt: new Date(), - }, - }) - }) -}) - -describe('find()', () => { - it('returns null if no job found', async () => { - vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(null) - const adapter = new PrismaAdapter({ db: mockDb }) - const job = await adapter.find({ - processName: 'test', - maxRuntime: 1000, - queue: 'foobar', - }) - - expect(job).toBeNull() - }) - - it('returns a job if found', async () => { - const mockJob = { id: 1 } - vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) - vi.spyOn(mockDb.backgroundJob, 'updateMany').mockReturnValue({ count: 1 }) - const adapter = new PrismaAdapter({ db: mockDb }) - const job = await adapter.find({ - processName: 'test', - maxRuntime: 1000, - queue: 'default', - }) - - expect(job).toEqual(mockJob) - }) - - it('increments the `attempts` count on the found job', async () => { - const mockJob = { id: 1, attempts: 0 } - vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) - const updateSpy = vi - .spyOn(mockDb.backgroundJob, 'updateMany') - .mockReturnValue({ count: 1 }) - const adapter = new PrismaAdapter({ db: mockDb }) - await adapter.find({ - processName: 'test', - maxRuntime: 1000, - queue: 'default', - }) - - expect(updateSpy).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ attempts: 1 }), - }), - ) - }) - - it('locks the job for the current process', async () => { - const mockJob = { id: 1, attempts: 0 } - vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) - const updateSpy = vi - .spyOn(mockDb.backgroundJob, 'updateMany') - .mockReturnValue({ count: 1 }) - const adapter = new PrismaAdapter({ db: mockDb }) - await adapter.find({ - processName: 'test-process', - maxRuntime: 1000, - queue: 'default', - }) - - expect(updateSpy).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ lockedBy: 'test-process' }), - }), - ) - }) - - it('locks the job with a current timestamp', async () => { - const mockJob = { id: 1, attempts: 0 } - vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) - const updateSpy = vi - .spyOn(mockDb.backgroundJob, 'updateMany') - .mockReturnValue({ count: 1 }) - const adapter = new PrismaAdapter({ db: mockDb }) - await adapter.find({ - processName: 'test-process', - maxRuntime: 1000, - queue: 'default', - }) - - expect(updateSpy).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ lockedAt: new Date() }), - }), - ) - }) -}) - -describe('success()', () => { - it('deletes the job from the DB', async () => { - const spy = vi.spyOn(mockDb.backgroundJob, 'delete') - const adapter = new PrismaAdapter({ db: mockDb }) - await adapter.success({ id: 1 }) - - expect(spy).toHaveBeenCalledWith({ where: { id: 1 } }) - }) -}) - -describe('failure()', () => { - it('updates the job by id', async () => { - const spy = vi.spyOn(mockDb.backgroundJob, 'update') - const adapter = new PrismaAdapter({ db: mockDb }) - await adapter.failure({ id: 1 }, new Error('test error')) - - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: 1 } }), - ) - }) - - it('clears the lock fields', async () => { - const spy = vi.spyOn(mockDb.backgroundJob, 'update') - const adapter = new PrismaAdapter({ db: mockDb }) - await adapter.failure({ id: 1 }, new Error('test error')) - - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ lockedAt: null, lockedBy: null }), - }), - ) - }) - - it('reschedules the job at a designated backoff time', async () => { - const spy = vi.spyOn(mockDb.backgroundJob, 'update') - const adapter = new PrismaAdapter({ db: mockDb }) - await adapter.failure({ id: 1, attempts: 10 }, new Error('test error')) - - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - runAt: new Date(new Date().getTime() + 1000 * 10 ** 4), - }), - }), - ) - }) - - it('records the error', async () => { - const spy = vi.spyOn(mockDb.backgroundJob, 'update') - const adapter = new PrismaAdapter({ db: mockDb }) - await adapter.failure({ id: 1, attempts: 10 }, new Error('test error')) - - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - lastError: expect.stringContaining('test error'), - }), - }), - ) - }) - - it('nullifies runtAt if max attempts reached', async () => { - const spy = vi.spyOn(mockDb.backgroundJob, 'update') - const adapter = new PrismaAdapter({ db: mockDb }) - await adapter.failure({ id: 1, attempts: 10 }, new Error('test error'), { - maxAttempts: 10, - }) - - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - runAt: null, - }), - }), - ) - }) - - it('marks the job as failed if max attempts reached', async () => { - const spy = vi.spyOn(mockDb.backgroundJob, 'update') - const adapter = new PrismaAdapter({ db: mockDb }) - await adapter.failure({ id: 1, attempts: 10 }, new Error('test error'), { - maxAttempts: 10, - deleteFailedJobs: false, - }) - - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - failedAt: new Date(), - }), - }), - ) - }) - - it('deletes the job if max attempts reached and deleteFailedJobs set to true', async () => { - const spy = vi.spyOn(mockDb.backgroundJob, 'delete') - const adapter = new PrismaAdapter({ db: mockDb }) - await adapter.failure({ id: 1, attempts: 10 }, new Error('test error'), { - maxAttempts: 10, - deleteFailedJobs: true, - }) - - expect(spy).toHaveBeenCalledWith({ where: { id: 1 } }) - }) -}) - -describe('clear()', () => { - it('deletes all jobs from the DB', async () => { - const spy = vi.spyOn(mockDb.backgroundJob, 'deleteMany') - - const adapter = new PrismaAdapter({ db: mockDb }) - await adapter.clear() - - expect(spy).toHaveBeenCalledOnce() - }) -}) - -describe('backoffMilliseconds()', () => { - it('returns the number of milliseconds to wait for the next run', () => { - expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(0)).toEqual(0) - expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(1)).toEqual( - 1000, - ) - expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(2)).toEqual( - 16000, - ) - expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(3)).toEqual( - 81000, - ) - expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(20)).toEqual( - 160000000, - ) - }) -}) diff --git a/packages/jobs/src/core/__tests__/RedwoodJob.test.js b/packages/jobs/src/core/__tests__/RedwoodJob.test.js deleted file mode 100644 index 59bb47e84c59..000000000000 --- a/packages/jobs/src/core/__tests__/RedwoodJob.test.js +++ /dev/null @@ -1,547 +0,0 @@ -import { describe, expect, vi, it, beforeEach, afterEach } from 'vitest' - -import * as errors from '../../core/errors' -import { RedwoodJob } from '../RedwoodJob' - -vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { - const originalCliHelpers = await importOriginal() - - return { - ...originalCliHelpers, - isTypeScriptProject: () => false, - } -}) - -vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) - -describe('static config', () => { - it('can set the adapter', () => { - const adapter = { schedule: vi.fn() } - - RedwoodJob.config({ adapter }) - - expect(RedwoodJob.adapter).toEqual(adapter) - }) - - it('can set the logger', () => { - const logger = { info: vi.fn() } - - RedwoodJob.config({ logger }) - - expect(RedwoodJob.logger).toEqual(logger) - }) - - it('can explictly set the adapter to falsy values for testing', () => { - RedwoodJob.config({ adapter: null }) - expect(RedwoodJob.adapter).toBeNull() - - RedwoodJob.config({ adapter: undefined }) - expect(RedwoodJob.adapter).toBeUndefined() - - RedwoodJob.config({ adapter: false }) - expect(RedwoodJob.adapter).toEqual(false) - }) -}) - -describe('constructor()', () => { - it('returns an instance of the job', () => { - const job = new RedwoodJob() - expect(job).toBeInstanceOf(RedwoodJob) - }) - - it('defaults some options', () => { - const job = new RedwoodJob() - expect(job.options).toEqual({ - queue: RedwoodJob.queue, - priority: RedwoodJob.priority, - }) - }) - - it('can set options for the job', () => { - const job = new RedwoodJob({ foo: 'bar' }) - expect(job.options.foo).toEqual('bar') - }) -}) - -describe('static set()', () => { - it('returns a job instance', () => { - const job = RedwoodJob.set({ wait: 300 }) - - expect(job).toBeInstanceOf(RedwoodJob) - }) - - it('sets options for the job', () => { - const job = RedwoodJob.set({ foo: 'bar' }) - - expect(job.options.foo).toEqual('bar') - }) - - it('sets the default queue', () => { - const job = RedwoodJob.set({ foo: 'bar' }) - - expect(job.options.queue).toEqual(RedwoodJob.queue) - }) - - it('sets the default priority', () => { - const job = RedwoodJob.set({ foo: 'bar' }) - - expect(job.options.priority).toEqual(RedwoodJob.priority) - }) - - it('can override the queue name set in the class', () => { - const job = RedwoodJob.set({ foo: 'bar', queue: 'priority' }) - - expect(job.options.queue).toEqual('priority') - }) - - it('can override the priority set in the class', () => { - const job = RedwoodJob.set({ foo: 'bar', priority: 10 }) - - expect(job.options.priority).toEqual(10) - }) -}) - -describe('instance set()', () => { - it('returns a job instance', () => { - const job = new RedwoodJob().set({ wait: 300 }) - - expect(job).toBeInstanceOf(RedwoodJob) - }) - - it('sets options for the job', () => { - const job = new RedwoodJob().set({ foo: 'bar' }) - - expect(job.options.foo).toEqual('bar') - }) - - it('sets the default queue', () => { - const job = new RedwoodJob().set({ foo: 'bar' }) - - expect(job.options.queue).toEqual(RedwoodJob.queue) - }) - - it('sets the default priority', () => { - const job = new RedwoodJob().set({ foo: 'bar' }) - - expect(job.options.priority).toEqual(RedwoodJob.priority) - }) - - it('can override the queue name set in the class', () => { - const job = new RedwoodJob().set({ foo: 'bar', queue: 'priority' }) - - expect(job.options.queue).toEqual('priority') - }) - - it('can override the priority set in the class', () => { - const job = new RedwoodJob().set({ foo: 'bar', priority: 10 }) - - expect(job.options.priority).toEqual(10) - }) -}) - -describe('get runAt()', () => { - it('returns the current time if no options are set', () => { - const job = new RedwoodJob() - - expect(job.runAt).toEqual(new Date()) - }) - - it('returns a datetime `wait` seconds in the future if option set', async () => { - const job = RedwoodJob.set({ wait: 300 }) - - expect(job.runAt).toEqual(new Date(Date.UTC(2024, 0, 1, 0, 5, 0))) - }) - - it('returns a datetime set to `waitUntil` if option set', async () => { - const futureDate = new Date(2030, 1, 2, 12, 34, 56) - const job = RedwoodJob.set({ - waitUntil: futureDate, - }) - - expect(job.runAt).toEqual(futureDate) - }) - - it('returns any datetime set directly on the instance', () => { - const futureDate = new Date(2030, 1, 2, 12, 34, 56) - const job = new RedwoodJob() - job.runAt = futureDate - - expect(job.runAt).toEqual(futureDate) - }) - - it('sets the computed time in the `options` property', () => { - const job = new RedwoodJob() - const runAt = job.runAt - - expect(job.options.runAt).toEqual(runAt) - }) -}) - -describe('set runAt()', () => { - it('allows manually setting runAt time directly on the instance', () => { - const futureDate = new Date(2030, 1, 2, 12, 34, 56) - const job = new RedwoodJob() - job.runAt = futureDate - - expect(job.runAt).toEqual(futureDate) - }) - - it('sets the `options.runAt` property', () => { - const futureDate = new Date(2030, 1, 2, 12, 34, 56) - const job = new RedwoodJob() - job.runAt = futureDate - - expect(job.options.runAt).toEqual(futureDate) - }) -}) - -describe('get queue()', () => { - it('defaults to queue set in class', () => { - const job = new RedwoodJob() - - expect(job.queue).toEqual(RedwoodJob.queue) - }) - - it('allows manually setting the queue name on an instance', () => { - const job = new RedwoodJob() - job.queue = 'priority' - - expect(job.queue).toEqual('priority') - }) - - it('prefers the queue set manually over queue set as an option', () => { - const job = RedwoodJob.set({ queue: 'priority' }) - job.queue = 'important' - - expect(job.queue).toEqual('important') - }) -}) - -describe('set queue()', () => { - it('sets the queue name in `options.queue`', () => { - const job = new RedwoodJob() - job.queue = 'priority' - - expect(job.options.queue).toEqual('priority') - }) -}) - -describe('get priority()', () => { - it('defaults to priority set in class', () => { - const job = new RedwoodJob() - - expect(job.priority).toEqual(RedwoodJob.priority) - }) - - it('allows manually setting the priority name on an instance', () => { - const job = new RedwoodJob() - job.priority = 10 - - expect(job.priority).toEqual(10) - }) - - it('prefers priority set manually over priority set as an option', () => { - const job = RedwoodJob.set({ priority: 20 }) - job.priority = 10 - - expect(job.priority).toEqual(10) - }) -}) - -describe('set priority()', () => { - it('sets the priority in `options.priority`', () => { - const job = new RedwoodJob() - job.priority = 10 - - expect(job.options.priority).toEqual(10) - }) -}) - -describe('static performLater()', () => { - beforeEach(() => { - vi.spyOn(console, 'info').mockImplementation(() => {}) - }) - - afterEach(() => { - vi.resetAllMocks() - }) - - it('invokes the instance performLater()', () => { - class TestJob extends RedwoodJob { - async perform() { - return 'done' - } - } - const spy = vi.spyOn(TestJob.prototype, 'performLater') - const mockAdapter = { schedule: vi.fn() } - RedwoodJob.config({ adapter: mockAdapter }) - - TestJob.performLater('foo', 'bar') - - expect(spy).toHaveBeenCalledWith('foo', 'bar') - }) -}) - -describe('instance performLater()', () => { - beforeEach(() => { - vi.spyOn(console, 'info').mockImplementation(() => {}) - }) - - afterEach(() => { - vi.resetAllMocks() - }) - - it('throws an error if no adapter is configured', async () => { - RedwoodJob.config({ adapter: undefined }) - - const job = new RedwoodJob() - - expect(() => job.performLater('foo', 'bar')).toThrow( - errors.AdapterNotConfiguredError, - ) - }) - - it('logs that the job is being scheduled', async () => { - class TestJob extends RedwoodJob { - async perform() { - return 'done' - } - } - const mockAdapter = { schedule: vi.fn() } - const mockLogger = { info: vi.fn() } - RedwoodJob.config({ adapter: mockAdapter, logger: mockLogger }) - const spy = vi.spyOn(mockLogger, 'info') - - await new TestJob().performLater('foo', 'bar') - - expect(spy).toHaveBeenCalledWith( - { - args: ['foo', 'bar'], - handler: 'TestJob', - priority: 50, - queue: 'default', - runAt: new Date(), - }, - '[RedwoodJob] Scheduling TestJob', - ) - }) - - it('calls the `schedule` function on the adapter', async () => { - class TestJob extends RedwoodJob { - async perform() { - return 'done' - } - } - const mockAdapter = { schedule: vi.fn() } - RedwoodJob.config({ adapter: mockAdapter }) - const spy = vi.spyOn(mockAdapter, 'schedule') - - await new TestJob().performLater('foo', 'bar') - - expect(spy).toHaveBeenCalledWith({ - handler: 'TestJob', - args: ['foo', 'bar'], - queue: 'default', - priority: 50, - runAt: new Date(), - }) - }) - - it('returns whatever the adapter returns', async () => { - class TestJob extends RedwoodJob { - async perform() { - return 'done' - } - } - const scheduleReturn = { status: 'scheduled' } - const mockAdapter = { - schedule: vi.fn(() => scheduleReturn), - } - RedwoodJob.config({ adapter: mockAdapter }) - - const result = await new TestJob().performLater('foo', 'bar') - - expect(result).toEqual(scheduleReturn) - }) - - it('catches any errors thrown during schedulding and throws custom error', async () => { - class TestJob extends RedwoodJob { - async perform() { - return 'done' - } - } - const mockAdapter = { - schedule: vi.fn(() => { - throw new Error('Could not schedule') - }), - } - RedwoodJob.config({ adapter: mockAdapter }) - - try { - await new TestJob().performLater('foo', 'bar') - } catch (e) { - expect(e).toBeInstanceOf(errors.SchedulingError) - expect(e.message).toEqual( - '[RedwoodJob] Exception when scheduling TestJob', - ) - expect(e.originalError.message).toEqual('Could not schedule') - } - }) -}) - -describe('static performNow()', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('invokes the instance performNow()', () => { - class TestJob extends RedwoodJob { - async perform() { - return 'done' - } - } - const spy = vi.spyOn(TestJob.prototype, 'performNow') - const mockAdapter = { schedule: vi.fn() } - RedwoodJob.config({ adapter: mockAdapter }) - - TestJob.performNow('foo', 'bar') - - expect(spy).toHaveBeenCalledWith('foo', 'bar') - }) -}) - -describe('instance performNow()', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('throws an error if perform() function is not implemented', async () => { - class TestJob extends RedwoodJob {} - const job = new TestJob() - - expect(() => job.perform('foo', 'bar')).toThrow(TypeError) - }) - - it('re-throws perform() error from performNow() if perform() function is not implemented', async () => { - class TestJob extends RedwoodJob {} - const job = new TestJob() - - expect(() => job.performNow('foo', 'bar')).toThrow(TypeError) - }) - - it('logs that the job is being run', async () => { - class TestJob extends RedwoodJob { - async perform() { - return 'done' - } - } - const mockAdapter = { schedule: vi.fn() } - const mockLogger = { info: vi.fn() } - RedwoodJob.config({ adapter: mockAdapter, logger: mockLogger }) - const spy = vi.spyOn(mockLogger, 'info') - - await new TestJob().performNow('foo', 'bar') - - expect(spy).toHaveBeenCalledWith( - { - args: ['foo', 'bar'], - handler: 'TestJob', - priority: 50, - queue: 'default', - runAt: new Date(), - }, - '[RedwoodJob] Running TestJob now', - ) - }) - - it('invokes the perform() function immediately', async () => { - class TestJob extends RedwoodJob { - async perform() { - return 'done' - } - } - - const spy = vi.spyOn(TestJob.prototype, 'perform') - - await new TestJob().performNow('foo', 'bar') - - expect(spy).toHaveBeenCalledWith('foo', 'bar') - }) - - it('returns whatever the perform() function returns', async () => { - const performReturn = { status: 'done' } - class TestJob extends RedwoodJob { - async perform() { - return performReturn - } - } - - const result = await new TestJob().performNow('foo', 'bar') - - expect(result).toEqual(performReturn) - }) - - it('catches any errors thrown during perform and throws custom error', async () => { - class TestJob extends RedwoodJob { - perform() { - throw new Error('Could not perform') - } - } - const mockAdapter = { - schedule: vi.fn(() => { - throw new Error('Could not schedule') - }), - } - RedwoodJob.config({ adapter: mockAdapter }) - - try { - new TestJob().performNow('foo', 'bar') - } catch (e) { - expect(e).toBeInstanceOf(errors.PerformError) - expect(e.message).toEqual('[TestJob] exception when running job') - expect(e.originalError.message).toEqual('Could not perform') - } - }) -}) - -describe('perform()', () => { - it('throws an error if not implemented', () => { - const job = new RedwoodJob() - - expect(() => job.perform()).toThrow(TypeError) - }) -}) - -describe('subclasses', () => { - it('can set its own queue', () => { - class MailerJob extends RedwoodJob { - static queue = 'mailers' - } - - // class access - expect(MailerJob.queue).toEqual('mailers') - expect(RedwoodJob.queue).toEqual('default') - - // instance access - const mailerJob = new MailerJob() - const redwoodJob = new RedwoodJob() - expect(mailerJob.queue).toEqual('mailers') - expect(redwoodJob.queue).toEqual('default') - }) - - it('can set its own priority', () => { - class PriorityJob extends RedwoodJob { - static priority = 10 - } - - // class access - expect(PriorityJob.priority).toEqual(10) - expect(RedwoodJob.priority).toEqual(50) - - // instance access - const priorityJob = new PriorityJob() - const redwoodJob = new RedwoodJob() - expect(priorityJob.priority).toEqual(10) - expect(redwoodJob.priority).toEqual(50) - }) -}) From 1306e31811324cd2f0b4cb89f5a3a779d7ccbeaa Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 24 Jul 2024 14:46:03 -0700 Subject: [PATCH 117/258] Silence log messages in test suite --- .../adapters/__tests__/PrismaAdapter.test.ts | 92 ++++++++++++------- .../src/core/__tests__/RedwoodJob.test.ts | 16 ++-- 2 files changed, 67 insertions(+), 41 deletions(-) diff --git a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts index 025a3e21f07c..de4c64600520 100644 --- a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts +++ b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts @@ -19,6 +19,13 @@ vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { let mockDb: PrismaClient +const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +} + beforeEach(() => { mockDb = { _activeProvider: 'sqlite', @@ -46,7 +53,7 @@ afterEach(() => { describe('constructor', () => { it('defaults this.model name', () => { - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) expect(adapter.model).toEqual(DEFAULTS.model) }) @@ -62,25 +69,27 @@ describe('constructor', () => { const adapter = new PrismaAdapter({ db: mockDb, model: 'Job', + logger: mockLogger, }) expect(adapter.model).toEqual('Job') }) it('throws an error with a model name that does not exist', () => { - expect(() => new PrismaAdapter({ db: mockDb, model: 'FooBar' })).toThrow( - errors.ModelNameError, - ) + expect( + () => + new PrismaAdapter({ db: mockDb, model: 'FooBar', logger: mockLogger }), + ).toThrow(errors.ModelNameError) }) it('sets this.accessor to the correct Prisma accessor', () => { - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) expect(adapter.accessor).toEqual(mockDb.backgroundJob) }) it('sets this.provider based on the active provider', () => { - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) expect(adapter.provider).toEqual('sqlite') }) @@ -91,7 +100,7 @@ describe('schedule()', () => { const createSpy = vi .spyOn(mockDb.backgroundJob, 'create') .mockReturnValue({ id: 1 }) - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) await adapter.schedule({ handler: 'RedwoodJob', args: ['foo', 'bar'], @@ -117,7 +126,7 @@ describe('schedule()', () => { describe('find()', () => { it('returns null if no job found', async () => { vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(null) - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) const job = await adapter.find({ processName: 'test', maxRuntime: 1000, @@ -131,7 +140,7 @@ describe('find()', () => { const mockJob = { id: 1 } vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) vi.spyOn(mockDb.backgroundJob, 'updateMany').mockReturnValue({ count: 1 }) - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) const job = await adapter.find({ processName: 'test', maxRuntime: 1000, @@ -147,7 +156,7 @@ describe('find()', () => { const updateSpy = vi .spyOn(mockDb.backgroundJob, 'updateMany') .mockReturnValue({ count: 1 }) - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) await adapter.find({ processName: 'test', maxRuntime: 1000, @@ -167,7 +176,7 @@ describe('find()', () => { const updateSpy = vi .spyOn(mockDb.backgroundJob, 'updateMany') .mockReturnValue({ count: 1 }) - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) await adapter.find({ processName: 'test-process', maxRuntime: 1000, @@ -187,7 +196,7 @@ describe('find()', () => { const updateSpy = vi .spyOn(mockDb.backgroundJob, 'updateMany') .mockReturnValue({ count: 1 }) - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) await adapter.find({ processName: 'test-process', maxRuntime: 1000, @@ -219,7 +228,10 @@ const mockPrismaJob = { describe('success()', () => { it('deletes the job from the DB', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'delete') - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ + db: mockDb, + logger: mockLogger, + }) await adapter.success(mockPrismaJob) expect(spy).toHaveBeenCalledWith({ where: { id: 1 } }) @@ -229,7 +241,7 @@ describe('success()', () => { describe('failure()', () => { it('updates the job by id', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) await adapter.failure(mockPrismaJob, new Error('test error')) expect(spy).toHaveBeenCalledWith( @@ -239,7 +251,7 @@ describe('failure()', () => { it('clears the lock fields', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) await adapter.failure(mockPrismaJob, new Error('test error')) expect(spy).toHaveBeenCalledWith( @@ -251,7 +263,7 @@ describe('failure()', () => { it('reschedules the job at a designated backoff time', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) await adapter.failure(mockPrismaJob, new Error('test error')) expect(spy).toHaveBeenCalledWith( @@ -265,7 +277,7 @@ describe('failure()', () => { it('records the error', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) await adapter.failure(mockPrismaJob, new Error('test error')) expect(spy).toHaveBeenCalledWith( @@ -279,7 +291,7 @@ describe('failure()', () => { it('nullifies runtAt if max attempts reached', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) await adapter.failure(mockPrismaJob, new Error('test error'), { maxAttempts: 10, }) @@ -295,7 +307,7 @@ describe('failure()', () => { it('marks the job as failed if max attempts reached', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) await adapter.failure(mockPrismaJob, new Error('test error'), { maxAttempts: 10, deleteFailedJobs: false, @@ -312,7 +324,7 @@ describe('failure()', () => { it('deletes the job if max attempts reached and deleteFailedJobs set to true', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'delete') - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) await adapter.failure(mockPrismaJob, new Error('test error'), { maxAttempts: 10, deleteFailedJobs: true, @@ -326,7 +338,7 @@ describe('clear()', () => { it('deletes all jobs from the DB', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'deleteMany') - const adapter = new PrismaAdapter({ db: mockDb }) + const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) await adapter.clear() expect(spy).toHaveBeenCalledOnce() @@ -335,18 +347,30 @@ describe('clear()', () => { describe('backoffMilliseconds()', () => { it('returns the number of milliseconds to wait for the next run', () => { - expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(0)).toEqual(0) - expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(1)).toEqual( - 1000, - ) - expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(2)).toEqual( - 16000, - ) - expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(3)).toEqual( - 81000, - ) - expect(new PrismaAdapter({ db: mockDb }).backoffMilliseconds(20)).toEqual( - 160000000, - ) + expect( + new PrismaAdapter({ db: mockDb, logger: mockLogger }).backoffMilliseconds( + 0, + ), + ).toEqual(0) + expect( + new PrismaAdapter({ db: mockDb, logger: mockLogger }).backoffMilliseconds( + 1, + ), + ).toEqual(1000) + expect( + new PrismaAdapter({ db: mockDb, logger: mockLogger }).backoffMilliseconds( + 2, + ), + ).toEqual(16000) + expect( + new PrismaAdapter({ db: mockDb, logger: mockLogger }).backoffMilliseconds( + 3, + ), + ).toEqual(81000) + expect( + new PrismaAdapter({ db: mockDb, logger: mockLogger }).backoffMilliseconds( + 20, + ), + ).toEqual(160000000) }) }) diff --git a/packages/jobs/src/core/__tests__/RedwoodJob.test.ts b/packages/jobs/src/core/__tests__/RedwoodJob.test.ts index 127656a91e8d..c477f99e6865 100644 --- a/packages/jobs/src/core/__tests__/RedwoodJob.test.ts +++ b/packages/jobs/src/core/__tests__/RedwoodJob.test.ts @@ -275,12 +275,12 @@ describe('set priority()', () => { describe('static performLater()', () => { beforeEach(() => { - vi.clearAllMocks() + vi.resetAllMocks() }) it('invokes the instance performLater()', () => { const spy = vi.spyOn(TestJob.prototype, 'performLater') - RedwoodJob.config({ adapter: mockAdapter }) + RedwoodJob.config({ adapter: mockAdapter, logger: mockLogger }) TestJob.performLater('foo', 'bar') @@ -295,7 +295,7 @@ describe('instance performLater()', () => { it('throws an error if no adapter is configured', async () => { // @ts-expect-error - testing JS scenario - TestJob.config({ adapter: undefined }) + TestJob.config({ adapter: undefined, logger: mockLogger }) const job = new TestJob() @@ -322,7 +322,7 @@ describe('instance performLater()', () => { }) it('calls the `schedule` function on the adapter', async () => { - RedwoodJob.config({ adapter: mockAdapter }) + RedwoodJob.config({ adapter: mockAdapter, logger: mockLogger }) await new TestJob().performLater('foo', 'bar') @@ -341,7 +341,7 @@ describe('instance performLater()', () => { ...mockAdapter, schedule: vi.fn(() => scheduleReturn), } - TestJob.config({ adapter }) + TestJob.config({ adapter, logger: mockLogger }) const result = await new TestJob().performLater('foo', 'bar') @@ -355,7 +355,7 @@ describe('instance performLater()', () => { throw new Error('Could not schedule') }), } - RedwoodJob.config({ adapter }) + RedwoodJob.config({ adapter, logger: mockLogger }) try { await new TestJob().performLater('foo', 'bar') @@ -398,6 +398,8 @@ describe('instance performNow()', () => { }) it('re-throws perform() error from performNow() if perform() function is not implemented', async () => { + RedwoodJob.config({ adapter: mockAdapter, logger: mockLogger }) + // @ts-expect-error - testing JS scenario class TestJob extends RedwoodJob {} const job = new TestJob() @@ -456,7 +458,7 @@ describe('instance performNow()', () => { }), } - RedwoodJob.config({ adapter }) + RedwoodJob.config({ adapter, logger: mockLogger }) try { new TestJobPerf().performNow('foo', 'bar') From 928a9214a0b1acc21371514f153fae192af12ad4 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 24 Jul 2024 16:25:36 -0700 Subject: [PATCH 118/258] Prettify and lint jobs config file after generator adds imports/exports --- packages/cli/src/commands/generate/job/job.js | 33 ++++++++++++++----- .../setup/jobs/templates/jobs.ts.template | 4 +-- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/generate/job/job.js b/packages/cli/src/commands/generate/job/job.js index 227ebc2fb741..06772d5722f3 100644 --- a/packages/cli/src/commands/generate/job/job.js +++ b/packages/cli/src/commands/generate/job/job.js @@ -2,13 +2,19 @@ import fs from 'node:fs' import path from 'node:path' import * as changeCase from 'change-case' +import execa from 'execa' import { Listr } from 'listr2' import terminalLink from 'terminal-link' import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' import { errorTelemetry } from '@redwoodjs/telemetry' -import { getPaths, transformTSToJS, writeFilesTask } from '../../../lib' +import { + getPaths, + prettify, + transformTSToJS, + writeFilesTask, +} from '../../../lib' import c from '../../../lib/colors' import { isTypeScriptProject } from '../../../lib/project' import { prepareForRollback } from '../../../lib/rollback' @@ -153,7 +159,7 @@ export const handler = async ({ name, force, ...rest }) => { validateName(name) const jobName = normalizeName(name) - const newJobExport = `${changeCase.camelCase(jobName)}Job: new ${jobName}Job()` + const newJobExport = `${changeCase.camelCase(jobName)}: new ${jobName}Job()` const tasks = new Listr( [ @@ -171,24 +177,35 @@ export const handler = async ({ name, force, ...rest }) => { const file = fs.readFileSync(getPaths().api.jobsConfig).toString() const newFile = file .replace( - /export const jobs = \{/, - `export const jobs = {\n ${newJobExport},`, + /^(export const jobs = \{)(.*)$/m, + `$1\n ${newJobExport},$2`, ) + .replace(/,\}/, ',\n}') .replace( /(import \{ db \} from 'src\/lib\/db')/, `import ${jobName}Job from 'src/jobs/${jobName}Job'\n$1`, ) - fs.writeFileSync(getPaths().api.jobsConfig, newFile) + + fs.writeFileSync( + getPaths().api.jobsConfig, + await prettify(getPaths().api.jobsConfig, newFile), + ) }, skip: () => { const file = fs.readFileSync(getPaths().api.jobsConfig).toString() - if (!file || !file.match(/export const jobs = \{/)) { + if (!file || !file.match(/^export const jobs = \{/m)) { return '`jobs` export not found, skipping' - } else if (file.match(newJobExport)) { - return `${jobName}Job already exported, skipping` } }, }, + { + title: 'Cleaning up...', + task: () => { + execa.commandSync( + `yarn eslint --fix --config ${getPaths().base}/node_modules/@redwoodjs/eslint-config/shared.js ${getPaths().api.jobsConfig}`, + ) + }, + }, ], { rendererOptions: { collapseSubtasks: false }, exitOnError: true }, ) diff --git a/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template b/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template index 2f1a05331529..8bfcdbf5341f 100644 --- a/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template +++ b/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template @@ -26,6 +26,4 @@ export const workerConfig: WorkerConfig = { // Export instances of all your jobs to make them easy to import and use: // export const jobs = { sample: new SampleJob(), email: new EmailJob() } -export const jobs: AvailableJobs = { - foo: new TestJob(), -} +export const jobs: AvailableJobs = {} From 499439f485da5cd12b494544905353b5c4f545f2 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 25 Jul 2024 15:11:39 -0700 Subject: [PATCH 119/258] Refactor, getters for all options, options back to REAL private, move constants to import --- packages/jobs/src/core/RedwoodJob.ts | 163 +++++------ .../src/core/__tests__/RedwoodJob.test.ts | 276 +++++++----------- packages/jobs/src/core/consts.ts | 5 + 3 files changed, 174 insertions(+), 270 deletions(-) diff --git a/packages/jobs/src/core/RedwoodJob.ts b/packages/jobs/src/core/RedwoodJob.ts index 3a7cda18bd0b..96463ec31236 100644 --- a/packages/jobs/src/core/RedwoodJob.ts +++ b/packages/jobs/src/core/RedwoodJob.ts @@ -1,21 +1,10 @@ // Base class for all jobs, providing a common interface for scheduling jobs. // At a minimum you must implement the `perform` method in your job subclass. -// -// Configuring RedwoodJob is very flexible. You can set the adapter and logger -// once and all subclasses will use it: -// -// RedwoodJob.config({ adapter, logger }) -// -// Or set them in the individual subclasses: -// -// class MyJob extends RedwoodJob { -// static adapter = new MyAdapter() -// static logger = new MyLogger() -// } import type { BaseAdapter } from '../adapters/BaseAdapter' import type { BasicLogger } from '../types' +import { DEFAULT_LOGGER, DEFAULT_QUEUE, DEFAULT_PRIORITY } from './consts' import { AdapterNotConfiguredError, SchedulingError, @@ -32,51 +21,47 @@ export interface JobSetOptions { waitUntil?: Date queue?: string priority?: number - logger?: BasicLogger - runAt?: Date } -export const DEFAULT_QUEUE = 'default' - -export const DEFAULT_PRIORITY = 50 - export abstract class RedwoodJob { - // The default queue for all jobs - static queue = DEFAULT_QUEUE + // The adapter to use for scheduling all jobs of this class + static adapter: BaseAdapter - // The default priority for all jobs - // Lower numbers are higher priority (1 is higher priority than 100) - static priority = DEFAULT_PRIORITY + // The logger to use when scheduling all jobs of the class. Defaults to + // `console` if not explicitly set. + static logger: BasicLogger = DEFAULT_LOGGER - // The adapter to use for scheduling jobs - static adapter: BaseAdapter + // The queue that all jobs of this class will be enqueued to + static queue: string = DEFAULT_QUEUE - // Set via the static `config` method - static logger: BasicLogger + // The priority that all jobs of this class will be given + // Lower numbers are higher priority (1 is executed before 100) + static priority: number = DEFAULT_PRIORITY // Configure all jobs to use a specific adapter and logger static config(options: JobConfigOptions) { if (Object.keys(options).includes('adapter')) { this.adapter = options.adapter } - this.logger = options?.logger || console + if ( + Object.keys(options).includes('logger') && + options.logger !== undefined + ) { + this.logger = options.logger + } } // Class method to schedule a job to run later - // const scheduleDetails = RedwoodJob.performLater('foo', 'bar') static performLater(this: new () => T, ...args: any[]) { return new this().performLater(...args) } // Class method to run the job immediately in the current process - // const result = RedwoodJob.performNow('foo', 'bar') static performNow(this: new () => T, ...args: any[]) { return new this().performNow(...args) } - // Set options on the job before enqueueing it: - // const job = RedwoodJob.set({ wait: 300 }) - // job.performLater('foo', 'bar') + // Set options on the job before enqueueing it static set( this: new (options: JobSetOptions) => T, options: JobSetOptions = {}, @@ -84,8 +69,8 @@ export abstract class RedwoodJob { return new this(options) } - // Private property to store options set on the job - private myOptions: JobSetOptions = {}; + // Private property to store options set on the job. Use `set` to modify + #options: JobSetOptions = {}; declare ['constructor']: typeof RedwoodJob @@ -93,33 +78,33 @@ export abstract class RedwoodJob { // automatically by .set() or .performLater() constructor(options: JobSetOptions = {}) { this.set(options) + + if (!this.constructor.adapter) { + throw new AdapterNotConfiguredError() + } } - // Set options on the job before enqueueing it: - // const job = RedwoodJob.set({ wait: 300 }) - // job.performLater('foo', 'bar') - set(options = {}) { - this.myOptions = { queue: this.queue, priority: this.priority, ...options } + // Set options on the job before enqueueing it, merges with any existing + // options set upon instantiation + set(options: JobSetOptions = {}) { + this.#options = { ...this.#options, ...options } return this } - // Instance method to schedule a job to run later - // const job = RedwoodJob - // const scheduleDetails = job.performLater('foo', 'bar') + // Schedule a job to run later performLater(...args: any[]) { this.logger.info( - this.payload(args), + this.#payload(args), `[RedwoodJob] Scheduling ${this.constructor.name}`, ) - return this.schedule(args) + return this.#schedule(args) } - // Instance method to runs the job immediately in the current process - // const result = RedwoodJob.performNow('foo', 'bar') + // Run the job immediately, within in the current process performNow(...args: any[]) { this.logger.info( - this.payload(args), + this.#payload(args), `[RedwoodJob] Running ${this.constructor.name} now`, ) @@ -140,41 +125,33 @@ export abstract class RedwoodJob { // Must be implemented by the subclass abstract perform(..._args: any[]): any - // Returns data sent to the adapter for scheduling - payload(args: any[]) { - return { - handler: this.constructor.name, - args, - runAt: this.runAt as Date, - queue: this.queue, - priority: this.priority, - } + // Make private this.#options available as a getter only + get options() { + return this.#options + } + + get adapter() { + return this.constructor.adapter } get logger() { - return this.myOptions?.logger || this.constructor.logger + return this.constructor.logger } - // Determines the name of the queue get queue() { - return this.myOptions?.queue || this.constructor.queue + return this.#options.queue || this.constructor.queue } - // Set the name of the queue directly on an instance of a job - set queue(value) { - this.myOptions = Object.assign(this.myOptions || {}, { queue: value }) + get priority() { + return this.#options.priority || this.constructor.priority } - // Determines the priority of the job - get priority() { - return this.myOptions?.priority || this.constructor.priority + get wait() { + return this.#options.wait } - // Set the priority of the job directly on an instance of a job - set priority(value) { - this.myOptions = Object.assign(this.myOptions || {}, { - priority: value, - }) + get waitUntil() { + return this.#options.waitUntil } // Determines when the job should run. @@ -183,41 +160,31 @@ export abstract class RedwoodJob { // - If a `wait` option is present it sets the number of seconds to wait // - If a `waitUntil` option is present it runs at that specific datetime get runAt() { - if (!this.myOptions?.runAt) { - this.myOptions = Object.assign(this.myOptions || {}, { - runAt: this.myOptions?.wait - ? new Date(new Date().getTime() + this.myOptions.wait * 1000) - : this.myOptions?.waitUntil - ? this.myOptions.waitUntil - : new Date(), - }) + if (this.#options.wait) { + return new Date(new Date().getTime() + this.#options.wait * 1000) + } else if (this.#options.waitUntil) { + return this.#options.waitUntil + } else { + return new Date() } - - return this.myOptions.runAt } - // Set the runAt time on a job directly: - // const job = new RedwoodJob() - // job.runAt = new Date(2030, 1, 2, 12, 34, 56) - // job.performLater() - set runAt(value) { - this.myOptions = Object.assign(this.myOptions || {}, { runAt: value }) - } - - // Make private this.myOptions available as a getter only - get options() { - return this.myOptions + // Private, computes the object to be sent to the adapter for scheduling + #payload(args: any[]) { + return { + handler: this.constructor.name, + args, + runAt: this.runAt, + queue: this.queue, + priority: this.priority, + } } // Private, schedules a job with the appropriate adapter, returns whatever // the adapter returns in response to a successful schedule. - private schedule(args: any[]) { - if (!this.constructor.adapter) { - throw new AdapterNotConfiguredError() - } - + #schedule(args: any[]) { try { - return this.constructor.adapter.schedule(this.payload(args)) + return this.constructor.adapter.schedule(this.#payload(args)) } catch (e: any) { throw new SchedulingError( `[RedwoodJob] Exception when scheduling ${this.constructor.name}`, diff --git a/packages/jobs/src/core/__tests__/RedwoodJob.test.ts b/packages/jobs/src/core/__tests__/RedwoodJob.test.ts index c477f99e6865..ccc2be32835e 100644 --- a/packages/jobs/src/core/__tests__/RedwoodJob.test.ts +++ b/packages/jobs/src/core/__tests__/RedwoodJob.test.ts @@ -2,6 +2,7 @@ import { describe, expect, vi, it, beforeEach } from 'vitest' import type CliHelpers from '@redwoodjs/cli-helpers' +import { DEFAULT_LOGGER, DEFAULT_QUEUE, DEFAULT_PRIORITY } from '../consts' import * as errors from '../errors' import { RedwoodJob } from '../RedwoodJob' @@ -34,28 +35,45 @@ const mockAdapter = { failure: (_job: { handler: string; args: any }, _error: Error) => {}, } -const jobConfig = { - adapter: mockAdapter, - logger: mockLogger, -} - class TestJob extends RedwoodJob { + static adapter = mockAdapter + async perform() { return 'done' } } -describe('static config', () => { +describe('static properties', () => { + it('sets a default logger', () => { + expect(RedwoodJob.logger).toEqual(DEFAULT_LOGGER) + }) + + it('sets a default queue', () => { + expect(RedwoodJob.queue).toEqual(DEFAULT_QUEUE) + }) + + it('sets a default priority', () => { + expect(RedwoodJob.priority).toEqual(DEFAULT_PRIORITY) + }) +}) + +describe('static config()', () => { it('can set the adapter', () => { - RedwoodJob.config(jobConfig) + RedwoodJob.config({ adapter: mockAdapter }) - expect(RedwoodJob.adapter).toEqual(jobConfig.adapter) + expect(RedwoodJob.adapter).toEqual(mockAdapter) }) it('can set the logger', () => { - RedwoodJob.config(jobConfig) + RedwoodJob.config({ adapter: mockAdapter, logger: mockLogger }) - expect(RedwoodJob.logger).toEqual(jobConfig.logger) + expect(RedwoodJob.logger).toEqual(mockLogger) + }) + + it('is inherited by subclasses', () => { + RedwoodJob.config({ adapter: mockAdapter }) + + expect(TestJob.adapter).toEqual(mockAdapter) }) }) @@ -65,18 +83,19 @@ describe('constructor()', () => { expect(job).toBeInstanceOf(RedwoodJob) }) - it('defaults some options', () => { - const job = new TestJob() - expect(job.options).toEqual({ - queue: RedwoodJob.queue, - priority: RedwoodJob.priority, - }) - }) - it('can set options for the job', () => { const job = new TestJob({ wait: 5 }) expect(job.options.wait).toEqual(5) }) + + it('throws an error if no adapter is configured', async () => { + // @ts-expect-error - testing JS scenario + class AdapterlessJob extends RedwoodJob { + static adapter = undefined + } + + expect(() => new AdapterlessJob()).toThrow(errors.AdapterNotConfiguredError) + }) }) describe('static set()', () => { @@ -86,24 +105,6 @@ describe('static set()', () => { expect(job).toBeInstanceOf(TestJob) }) - it('sets options for the job', () => { - const job = TestJob.set({ runAt: new Date(700) }) - - expect(job.options.runAt?.getTime()).toEqual(new Date(700).getTime()) - }) - - it('sets the default queue', () => { - const job = TestJob.set({ priority: 3 }) - - expect(job.options.queue).toEqual(TestJob.queue) - }) - - it('sets the default priority', () => { - const job = TestJob.set({ queue: 'bar' }) - - expect(job.options.priority).toEqual(TestJob.priority) - }) - it('can override the queue name set in the class', () => { const job = TestJob.set({ priority: 5, queue: 'priority' }) @@ -119,157 +120,138 @@ describe('static set()', () => { describe('instance set()', () => { it('returns a job instance', () => { - const job = new TestJob().set({ wait: 300 }) + const job = new TestJob().set() expect(job).toBeInstanceOf(TestJob) }) it('sets options for the job', () => { - const job = new TestJob().set({ runAt: new Date(700) }) + const job = new TestJob().set({ queue: 'foo', priority: 10, wait: 300 }) - expect(job.options.runAt?.getTime()).toEqual(new Date(700).getTime()) + expect(job.options).toEqual({ queue: 'foo', priority: 10, wait: 300 }) }) - it('sets the default queue', () => { - const job = new TestJob().set({ foo: 'bar' }) + it('overrides initialization options', () => { + const job = new TestJob({ queue: 'foo' }) + job.set({ queue: 'bar' }) - expect(job.options.queue).toEqual(TestJob.queue) + expect(job.queue).toEqual('bar') }) - it('sets the default priority', () => { - const job = new TestJob().set({ foo: 'bar' }) + it('does not override different options', () => { + const job = new TestJob({ priority: 10 }) + job.set({ queue: 'foo' }) - expect(job.options.priority).toEqual(TestJob.priority) + expect(job.priority).toEqual(10) + expect(job.queue).toEqual('foo') }) - it('can override the queue name set in the class', () => { - const job = new TestJob().set({ foo: 'bar', queue: 'priority' }) + it('can override the static (class) queue', () => { + const job = new TestJob() + expect(job.queue).toEqual(DEFAULT_QUEUE) - expect(job.options.queue).toEqual('priority') + job.set({ queue: 'random' }) + expect(job.options.queue).toEqual('random') }) - it('can override the priority set in the class', () => { - const job = new TestJob().set({ foo: 'bar', priority: 10 }) + it('can override the static (class) priority', () => { + const job = new TestJob() + expect(job.priority).toEqual(DEFAULT_PRIORITY) + job.set({ priority: 10 }) expect(job.options.priority).toEqual(10) }) }) -describe('get runAt()', () => { - it('returns the current time if no options are set', () => { - const job = new TestJob() - - expect(job.runAt).toEqual(new Date()) - }) - - it('returns a datetime `wait` seconds in the future if option set', async () => { - const job = TestJob.set({ wait: 300 }) +describe('get options()', () => { + it('returns the options set in the class', () => { + const job = new TestJob({ queue: 'foo' }) - expect(job.runAt).toEqual(new Date(Date.UTC(2024, 0, 1, 0, 5, 0))) - }) - - it('returns a datetime set to `waitUntil` if option set', async () => { - const futureDate = new Date(2030, 1, 2, 12, 34, 56) - const job = TestJob.set({ - waitUntil: futureDate, - }) - - expect(job.runAt).toEqual(futureDate) + expect(job.options).toEqual({ queue: 'foo' }) }) +}) - it('returns any datetime set directly on the instance', () => { - const futureDate = new Date(2030, 1, 2, 12, 34, 56) +describe('get adapter()', () => { + it('returns the adapter set in the class', () => { const job = new TestJob() - job.runAt = futureDate - expect(job.runAt).toEqual(futureDate) + expect(job.adapter).toEqual(mockAdapter) }) +}) - it('sets the computed time in the `options` property', () => { +describe('get logger()', () => { + it('returns the logger set in the class', () => { const job = new TestJob() - const runAt = job.runAt - expect(job.options.runAt).toEqual(runAt) + expect(job.logger).toEqual(mockLogger) }) }) -describe('set runAt()', () => { - it('allows manually setting runAt time directly on the instance', () => { - const futureDate = new Date(2030, 1, 2, 12, 34, 56) +describe('get queue()', () => { + it('returns the queue set in the class if no option set', () => { const job = new TestJob() - job.runAt = futureDate - expect(job.runAt).toEqual(futureDate) + expect(job.queue).toEqual(DEFAULT_QUEUE) }) - it('sets the `options.runAt` property', () => { - const futureDate = new Date(2030, 1, 2, 12, 34, 56) - const job = new TestJob() - job.runAt = futureDate + it('returns the queue set in the options', () => { + const job = new TestJob({ queue: 'foo' }) - expect(job.options.runAt).toEqual(futureDate) + expect(job.queue).toEqual('foo') }) }) -describe('get queue()', () => { - it('defaults to queue set in class', () => { - const job = new TestJob() - - expect(job.queue).toEqual(TestJob.queue) - }) - - it('allows manually setting the queue name on an instance', () => { +describe('get priority()', () => { + it('returns the priority set in the class if no option set', () => { const job = new TestJob() - job.queue = 'priority' - expect(job.queue).toEqual('priority') + expect(job.priority).toEqual(DEFAULT_PRIORITY) }) - it('prefers the queue set manually over queue set as an option', () => { - const job = TestJob.set({ queue: 'priority' }) - job.queue = 'important' + it('returns the priority set in the options', () => { + const job = new TestJob({ priority: 10 }) - expect(job.queue).toEqual('important') + expect(job.priority).toEqual(10) }) }) -describe('set queue()', () => { - it('sets the queue name in `options.queue`', () => { - const job = new TestJob() - job.queue = 'priority' +describe('get wait()', () => { + it('returns the wait set in the options', () => { + const job = new TestJob({ wait: 10 }) - expect(job.options.queue).toEqual('priority') + expect(job.wait).toEqual(10) }) }) -describe('get priority()', () => { - it('defaults to priority set in class', () => { - const job = new TestJob() +describe('get waitUntil()', () => { + it('returns the waitUntil set in the options', () => { + const futureDate = new Date(2025, 0, 1) + const job = new TestJob({ waitUntil: futureDate }) - expect(job.priority).toEqual(TestJob.priority) + expect(job.waitUntil).toEqual(futureDate) }) +}) - it('allows manually setting the priority name on an instance', () => { +describe('get runAt()', () => { + it('returns the current time if no options are set', () => { const job = new TestJob() - job.priority = 10 - expect(job.priority).toEqual(10) + expect(job.runAt).toEqual(new Date()) }) - it('prefers priority set manually over priority set as an option', () => { - const job = TestJob.set({ priority: 20 }) - job.priority = 10 + it('returns a datetime `wait` seconds in the future if option set', async () => { + const job = new TestJob().set({ wait: 300 }) - expect(job.priority).toEqual(10) + expect(job.runAt).toEqual(new Date(Date.UTC(2024, 0, 1, 0, 5, 0))) // 300 seconds in the future }) -}) -describe('set priority()', () => { - it('sets the priority in `options.priority`', () => { - const job = new TestJob() - job.priority = 10 + it('returns a datetime set to `waitUntil` if option set', async () => { + const futureDate = new Date(2030, 1, 2, 12, 34, 56) + const job = new TestJob().set({ + waitUntil: futureDate, + }) - expect(job.options.priority).toEqual(10) + expect(job.runAt).toEqual(futureDate) }) }) @@ -293,17 +275,6 @@ describe('instance performLater()', () => { vi.clearAllMocks() }) - it('throws an error if no adapter is configured', async () => { - // @ts-expect-error - testing JS scenario - TestJob.config({ adapter: undefined, logger: mockLogger }) - - const job = new TestJob() - - expect(() => job.performLater('foo', 'bar')).toThrow( - errors.AdapterNotConfiguredError, - ) - }) - it('logs that the job is being scheduled', async () => { TestJob.config({ adapter: mockAdapter, logger: mockLogger }) @@ -478,42 +449,3 @@ describe('perform()', () => { expect(() => job.perform()).toThrow(TypeError) }) }) - -describe('subclasses', () => { - it('can set its own queue', () => { - class MailerJob extends RedwoodJob { - static queue = 'mailers' - - perform() { - return 'done' - } - } - - // class access - expect(MailerJob.queue).toEqual('mailers') - expect(RedwoodJob.queue).toEqual('default') - - // instance access (not including RedwoodJob here, becuase it can't be - // instantiated since it's abstract) - const mailerJob = new MailerJob() - expect(mailerJob.queue).toEqual('mailers') - }) - - it('can set its own priority', () => { - class PriorityJob extends RedwoodJob { - static priority = 10 - - perform() { - return 'done' - } - } - - // class access - expect(PriorityJob.priority).toEqual(10) - expect(RedwoodJob.priority).toEqual(50) - - // instance access (again, not testing RedwoodJob. See comment above) - const priorityJob = new PriorityJob() - expect(priorityJob.priority).toEqual(10) - }) -}) diff --git a/packages/jobs/src/core/consts.ts b/packages/jobs/src/core/consts.ts index f71eeda35f46..b17f5cf0099c 100644 --- a/packages/jobs/src/core/consts.ts +++ b/packages/jobs/src/core/consts.ts @@ -1,4 +1,9 @@ +import console from 'node:console' + export const DEFAULT_MAX_ATTEMPTS = 24 export const DEFAULT_MAX_RUNTIME = 14_400 // 4 hours in seconds export const DEFAULT_SLEEP_DELAY = 5 // 5 seconds export const DEFAULT_DELETE_FAILED_JOBS = false +export const DEFAULT_LOGGER = console +export const DEFAULT_QUEUE = 'default' +export const DEFAULT_PRIORITY = 50 From 7854c8096a9880efe03c19059640938b911acdbd Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 25 Jul 2024 16:00:02 -0700 Subject: [PATCH 120/258] Runner script refactor: accept most config options as command line flags, only pull adapter and logger from jobs config file --- packages/jobs/src/bins/rw-jobs-worker.ts | 69 ++++++++++++++----- packages/jobs/src/bins/rw-jobs.ts | 84 +++++++++++++++++------- packages/jobs/src/core/Worker.ts | 4 +- packages/jobs/src/core/loaders.ts | 15 +---- 4 files changed, 118 insertions(+), 54 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index 28400e4536f0..f841b64602c0 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -13,7 +13,13 @@ import process from 'node:process' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' -import { loadWorkerConfig } from '../core/loaders' +import { + DEFAULT_DELETE_FAILED_JOBS, + DEFAULT_MAX_ATTEMPTS, + DEFAULT_MAX_RUNTIME, + DEFAULT_SLEEP_DELAY, +} from '../core/consts' +import { loadJobsConfig } from '../core/loaders' import { Worker } from '../core/Worker' import type { BasicLogger } from '../types' @@ -25,35 +31,47 @@ const parseArgs = (argv: string[]) => { 'Starts a single RedwoodJob worker to process background jobs\n\nUsage: $0 [options]', ) .option('id', { - alias: 'i', type: 'number', description: 'The worker ID', default: 0, }) .option('queue', { - alias: 'q', type: 'string', description: 'The named queue to work on', default: null, }) .option('workoff', { - alias: 'o', type: 'boolean', default: false, description: 'Work off all jobs in the queue and exit', }) - .option('config', { - alias: 'c', - type: 'string', - default: 'workerConfig', - description: 'Name of the exported variable containing the worker config', - }) .option('clear', { - alias: 'd', type: 'boolean', default: false, description: 'Remove all jobs in the queue and exit', }) + .option('maxAttempts', { + type: 'number', + default: DEFAULT_MAX_ATTEMPTS, + description: 'The maximum number of times a job can be attempted', + }) + .option('maxRuntime', { + type: 'number', + default: DEFAULT_MAX_RUNTIME, + description: 'The maximum number of seconds a job can run', + }) + .option('sleepDelay', { + type: 'number', + default: DEFAULT_SLEEP_DELAY, + description: + 'The maximum number of seconds to wait between polling for jobs', + }) + .option('deleteFailedJobs', { + type: 'boolean', + default: DEFAULT_DELETE_FAILED_JOBS, + description: + 'Whether to remove failed jobs from the queue after max attempts', + }) .help().argv } @@ -104,26 +122,45 @@ const setupSignals = ({ } const main = async () => { - const { id, queue, config, clear, workoff } = await parseArgs(process.argv) + const { + id, + queue, + clear, + workoff, + maxAttempts, + maxRuntime, + sleepDelay, + deleteFailedJobs, + } = await parseArgs(process.argv) setProcessTitle({ id, queue }) - let workerConfig + let jobsConfig + // Pull the complex config options we can't pass on the command line directly + // from the app's jobs config file: `adapter` and `logger`. Remaining config + // is passed as command line flags. The rw-jobs script pulls THOSE config + // options from the jobs config, but if you're not using that script you need + // to pass manually. Calling this script directly is ADVANCED USAGE ONLY! try { - workerConfig = await loadWorkerConfig(config) + jobsConfig = await loadJobsConfig() } catch (e) { console.error(e) process.exit(1) } - const logger = workerConfig.logger || console + const logger = jobsConfig.logger || console logger.info( `[${process.title}] Starting work at ${new Date().toISOString()}...`, ) const worker = new Worker({ - ...workerConfig, + adapter: jobsConfig.adapter, + logger, + maxAttempts, + maxRuntime, + sleepDelay, + deleteFailedJobs, processName: process.title, queue, workoff, diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index 09a68f462f22..a980196cef4a 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -14,10 +14,11 @@ import yargs from 'yargs/yargs' import { loadEnvFiles } from '@redwoodjs/cli-helpers/dist/lib/loadEnvFiles.js' -import { loadWorkerConfig } from '../core/loaders' +import { loadJobsConfig } from '../core/loaders' +import type { WorkerConfig } from '../core/Worker' import type { BasicLogger } from '../types' -export type WorkerConfig = Array<[string | null, number]> // [queue, id] +export type NumWorkersConfig = Array<[string | null, number]> // [queue, id] loadEnvFiles() @@ -84,12 +85,12 @@ const parseArgs = (argv: string[]) => { return { workerDef: parsed.n, command: parsed._[0] } } -const buildWorkerConfig = (workerDef: string): WorkerConfig => { +const buildNumWorkers = (workerDef: string): NumWorkersConfig => { // Builds up an array of arrays, with queue name and id: // `-n default:2,email:1` => [ ['default', 0], ['default', 1], ['email', 0] ] // If only given a number of workers then queue name is null (all queues): // `-n 2` => [ [null, 0], [null, 1] ] - const workers: WorkerConfig = [] + const workers: NumWorkersConfig = [] // default to one worker for commands that don't specify if (!workerDef) { @@ -113,32 +114,48 @@ const buildWorkerConfig = (workerDef: string): WorkerConfig => { } const startWorkers = ({ - workerConfig, + numWorkers, + workerConfig = {}, detach = false, workoff = false, logger, }: { + numWorkers: NumWorkersConfig workerConfig: WorkerConfig detach?: boolean workoff?: boolean logger: BasicLogger }) => { - logger.warn(`Starting ${workerConfig.length} worker(s)...`) + logger.warn(`Starting ${numWorkers.length} worker(s)...`) - return workerConfig.map(([queue, id]) => { + return numWorkers.map(([queue, id]) => { // list of args to send to the forked worker script const workerArgs: string[] = ['--id', id.toString()] - // add the queue name if present if (queue) { workerArgs.push('--queue', queue) } - // are we in workoff mode? if (workoff) { workerArgs.push('--workoff') } + if (workerConfig.maxAttempts) { + workerArgs.push('--max-attempts', workerConfig.maxAttempts.toString()) + } + + if (workerConfig.maxRuntime) { + workerArgs.push('--max-runtime', workerConfig.maxRuntime.toString()) + } + + if (workerConfig.deleteFailedJobs) { + workerArgs.push('--delete-failed-jobs') + } + + if (workerConfig.sleepDelay) { + workerArgs.push('--sleep-delay', workerConfig.sleepDelay.toString()) + } + // fork the worker process // TODO squiggles under __dirname, but import.meta.dirname blows up when running the process const worker = fork(path.join(__dirname, 'rw-jobs-worker.js'), workerArgs, { @@ -225,19 +242,19 @@ const findProcessId = async (name: string): Promise => { // TODO add support for stopping with SIGTERM or SIGKILL? const stopWorkers = async ({ - workerConfig, + numWorkers, signal = 'SIGINT', logger, }: { - workerConfig: WorkerConfig + numWorkers: NumWorkersConfig signal: string logger: BasicLogger }) => { logger.warn( - `Stopping ${workerConfig.length} worker(s) gracefully (${signal})...`, + `Stopping ${numWorkers.length} worker(s) gracefully (${signal})...`, ) - for (const [queue, id] of workerConfig) { + for (const [queue, id] of numWorkers) { const workerTitle = `rw-jobs-worker${queue ? `.${queue}` : ''}.${id}` const processId = await findProcessId(workerTitle) @@ -265,14 +282,14 @@ const clearQueue = ({ logger }: { logger: BasicLogger }) => { const main = async () => { const { workerDef, command } = parseArgs(process.argv) - const workerConfig = buildWorkerConfig(workerDef) + const numWorkers = buildNumWorkers(workerDef) - // user may have defined a custom logger, so use that if it exists - const libWorkerConfig = await loadWorkerConfig() + // get the worker config defined in the app's job config file + const jobsConfig = await loadJobsConfig() let logger - if (libWorkerConfig.logger) { - logger = libWorkerConfig.logger + if (jobsConfig.logger) { + logger = jobsConfig.logger } else { logger = console } @@ -281,24 +298,43 @@ const main = async () => { switch (command) { case 'start': - startWorkers({ workerConfig, detach: true, logger }) + startWorkers({ + numWorkers, + workerConfig: jobsConfig.workerConfig, + detach: true, + logger, + }) return process.exit(0) case 'restart': - await stopWorkers({ workerConfig, signal: 'SIGINT', logger }) - startWorkers({ workerConfig, detach: true, logger }) + await stopWorkers({ numWorkers, signal: 'SIGINT', logger }) + startWorkers({ + numWorkers, + workerConfig: jobsConfig.workerConfig, + detach: true, + logger, + }) return process.exit(0) case 'work': return signalSetup({ - workers: startWorkers({ workerConfig, logger }), + workers: startWorkers({ + numWorkers, + workerConfig: jobsConfig.workerConfig, + logger, + }), logger, }) case 'workoff': return signalSetup({ - workers: startWorkers({ workerConfig, workoff: true, logger }), + workers: startWorkers({ + numWorkers, + workerConfig: jobsConfig.workerConfig, + workoff: true, + logger, + }), logger, }) case 'stop': - return await stopWorkers({ workerConfig, signal: 'SIGINT', logger }) + return await stopWorkers({ numWorkers, signal: 'SIGINT', logger }) case 'clear': return clearQueue({ logger }) } diff --git a/packages/jobs/src/core/Worker.ts b/packages/jobs/src/core/Worker.ts index 6f835f15dbcc..50251b50b384 100644 --- a/packages/jobs/src/core/Worker.ts +++ b/packages/jobs/src/core/Worker.ts @@ -18,8 +18,6 @@ import { Executor } from './Executor' // The options set in api/src/lib/jobs.ts export interface WorkerConfig { - adapter: BaseAdapter - logger?: BasicLogger maxAttempts?: number maxRuntime?: number deleteFailedJobs?: boolean @@ -29,6 +27,8 @@ export interface WorkerConfig { // Additional options that the rw-jobs-worker process will set when // instantiatng the Worker class interface Options { + adapter: BaseAdapter + logger?: BasicLogger clear?: boolean processName?: string queue?: string | null diff --git a/packages/jobs/src/core/loaders.ts b/packages/jobs/src/core/loaders.ts index f21e2e6c724b..73a7b1f6c681 100644 --- a/packages/jobs/src/core/loaders.ts +++ b/packages/jobs/src/core/loaders.ts @@ -6,11 +6,7 @@ import fg from 'fast-glob' import { registerApiSideBabelHook } from '@redwoodjs/babel-config' import { getPaths } from '@redwoodjs/project-config' -import { - WorkerConfigNotFoundError, - JobsLibNotFoundError, - JobNotFoundError, -} from './errors' +import { JobsLibNotFoundError, JobNotFoundError } from './errors' // TODO Don't use this in production, import from dist directly registerApiSideBabelHook() @@ -21,15 +17,10 @@ export function makeFilePath(path: string) { // Loads the named export from the app's jobs config in api/src/lib/jobs.{js,ts} // to configure the worker, defaults to `workerConfig` -export const loadWorkerConfig = async (name = 'workerConfig') => { +export const loadJobsConfig = async () => { const jobsConfigPath = getPaths().api.jobsConfig if (jobsConfigPath) { - const jobsModule = require(jobsConfigPath) - if (jobsModule[name]) { - return jobsModule[name] - } else { - throw new WorkerConfigNotFoundError(name) - } + return require(jobsConfigPath) } else { throw new JobsLibNotFoundError() } From e68567e0bd17e0c253dbd9be1039977a99696b4d Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 25 Jul 2024 16:24:41 -0700 Subject: [PATCH 121/258] Can pass name of adapter and logger to use in individual workers --- packages/jobs/src/bins/rw-jobs-worker.ts | 34 ++++++++++++++++++++++-- packages/jobs/src/core/consts.ts | 5 ++++ packages/jobs/src/core/errors.ts | 15 +++++++++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index f841b64602c0..036785a2852a 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -18,7 +18,10 @@ import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_RUNTIME, DEFAULT_SLEEP_DELAY, + DEFAULT_ADAPTER_NAME, + DEFAULT_LOGGER_NAME, } from '../core/consts' +import { AdapterNotFoundError, LoggerNotFoundError } from '../core/errors' import { loadJobsConfig } from '../core/loaders' import { Worker } from '../core/Worker' import type { BasicLogger } from '../types' @@ -72,6 +75,17 @@ const parseArgs = (argv: string[]) => { description: 'Whether to remove failed jobs from the queue after max attempts', }) + .option('adapter', { + type: 'string', + default: DEFAULT_ADAPTER_NAME, + description: + 'Name of the exported variable from the jobs config file that contains the adapter', + }) + .option('logger', { + type: 'string', + description: + 'Name of the exported variable from the jobs config file that contains the adapter', + }) .help().argv } @@ -131,6 +145,8 @@ const main = async () => { maxRuntime, sleepDelay, deleteFailedJobs, + adapter: adapterName, + logger: loggerName, } = await parseArgs(process.argv) setProcessTitle({ id, queue }) @@ -148,14 +164,28 @@ const main = async () => { process.exit(1) } - const logger = jobsConfig.logger || console + // Exit if the named adapter isn't exported + if (!jobsConfig[adapterName]) { + throw new AdapterNotFoundError(adapterName) + } + + // Exit if the named logger isn't exported (if one was provided) + if (loggerName && !jobsConfig[loggerName]) { + throw new LoggerNotFoundError(loggerName) + } + + // if a named logger was provided, use it, otherwise fall back to the default + // name, otherwise just use the console + const logger = loggerName + ? jobsConfig[loggerName] + : jobsConfig[DEFAULT_LOGGER_NAME] || console logger.info( `[${process.title}] Starting work at ${new Date().toISOString()}...`, ) const worker = new Worker({ - adapter: jobsConfig.adapter, + adapter: jobsConfig[adapterName], logger, maxAttempts, maxRuntime, diff --git a/packages/jobs/src/core/consts.ts b/packages/jobs/src/core/consts.ts index b17f5cf0099c..4a0db14279db 100644 --- a/packages/jobs/src/core/consts.ts +++ b/packages/jobs/src/core/consts.ts @@ -7,3 +7,8 @@ export const DEFAULT_DELETE_FAILED_JOBS = false export const DEFAULT_LOGGER = console export const DEFAULT_QUEUE = 'default' export const DEFAULT_PRIORITY = 50 + +// the name of the exported variable from the jobs config file that contains the adapter +export const DEFAULT_ADAPTER_NAME = 'adapter' +// the name of the exported variable from the jobs config file that contains the logger +export const DEFAULT_LOGGER_NAME = 'logger' diff --git a/packages/jobs/src/core/errors.ts b/packages/jobs/src/core/errors.ts index ce7cf9ac7f6a..a0c12008b510 100644 --- a/packages/jobs/src/core/errors.ts +++ b/packages/jobs/src/core/errors.ts @@ -64,8 +64,19 @@ export class JobsLibNotFoundError extends RedwoodJobError { // Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js export class AdapterNotFoundError extends RedwoodJobError { - constructor() { - super(`api/src/lib/${JOBS_CONFIG_FILENAME} does not export \`adapter\``) + constructor(name: string) { + super( + `api/src/lib/${JOBS_CONFIG_FILENAME} does not export an adapter named \`${name}\``, + ) + } +} + +// Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js +export class LoggerNotFoundError extends RedwoodJobError { + constructor(name: string) { + super( + `api/src/lib/${JOBS_CONFIG_FILENAME} does not export a logger named \`${name}\``, + ) } } From 767e1397c9d30469677ad88763d928e4b1905953 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 25 Jul 2024 17:11:11 -0700 Subject: [PATCH 122/258] Load jobs and job config from /dist in production --- packages/jobs/src/core/loaders.ts | 24 +++++++++++++++++++----- packages/project-config/src/paths.ts | 9 ++++++++- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/jobs/src/core/loaders.ts b/packages/jobs/src/core/loaders.ts index 73a7b1f6c681..0c13a5fa53c9 100644 --- a/packages/jobs/src/core/loaders.ts +++ b/packages/jobs/src/core/loaders.ts @@ -8,8 +8,9 @@ import { getPaths } from '@redwoodjs/project-config' import { JobsLibNotFoundError, JobNotFoundError } from './errors' -// TODO Don't use this in production, import from dist directly -registerApiSideBabelHook() +if (process.env.NODE_ENV !== 'production') { + registerApiSideBabelHook() +} export function makeFilePath(path: string) { return pathToFileURL(path).href @@ -18,7 +19,13 @@ export function makeFilePath(path: string) { // Loads the named export from the app's jobs config in api/src/lib/jobs.{js,ts} // to configure the worker, defaults to `workerConfig` export const loadJobsConfig = async () => { - const jobsConfigPath = getPaths().api.jobsConfig + const jobsConfigPath = + process.env.NODE_ENV === 'production' + ? getPaths().api.distJobsConfig + : getPaths().api.jobsConfig + + console.info('loading config from', jobsConfigPath) + if (jobsConfigPath) { return require(jobsConfigPath) } else { @@ -28,12 +35,19 @@ export const loadJobsConfig = async () => { // Loads a job from the app's filesystem in api/src/jobs export const loadJob = async (name: string) => { + const baseJobsPath = + process.env.NODE_ENV === 'production' + ? getPaths().api.distJobs + : getPaths().api.jobs + + console.info('loading jobs from', baseJobsPath) + // Specifying {js,ts} extensions, so we don't accidentally try to load .json // files or similar - const files = fg.sync(`**/${name}.{js,ts}`, { cwd: getPaths().api.jobs }) + const files = fg.sync(`**/${name}.{js,ts}`, { cwd: baseJobsPath }) if (!files[0]) { throw new JobNotFoundError(name) } - const jobModule = require(path.join(getPaths().api.jobs, files[0])) + const jobModule = require(path.join(baseJobsPath, files[0])) return jobModule } diff --git a/packages/project-config/src/paths.ts b/packages/project-config/src/paths.ts index 75a11b4cf05d..1527debd4e95 100644 --- a/packages/project-config/src/paths.ts +++ b/packages/project-config/src/paths.ts @@ -24,7 +24,9 @@ export interface NodeTargetPaths { models: string mail: string jobs: string + distJobs: string jobsConfig: string | null + distJobsConfig: string | null logger: string | null } @@ -105,6 +107,7 @@ const PATH_API_DIR_SERVICES = 'api/src/services' const PATH_API_DIR_DIRECTIVES = 'api/src/directives' const PATH_API_DIR_SUBSCRIPTIONS = 'api/src/subscriptions' const PATH_API_DIR_SRC = 'api/src' +const PATH_API_DIR_DIST = 'api/dist' const PATH_WEB_ROUTES = 'web/src/Routes' // .jsx|.tsx const PATH_WEB_DIR_LAYOUTS = 'web/src/layouts/' const PATH_WEB_DIR_PAGES = 'web/src/pages/' @@ -210,12 +213,16 @@ export const getPaths = (BASE_DIR: string = getBaseDir()): Paths => { directives: path.join(BASE_DIR, PATH_API_DIR_DIRECTIVES), subscriptions: path.join(BASE_DIR, PATH_API_DIR_SUBSCRIPTIONS), src: path.join(BASE_DIR, PATH_API_DIR_SRC), - dist: path.join(BASE_DIR, 'api/dist'), + dist: path.join(BASE_DIR, PATH_API_DIR_DIST), types: path.join(BASE_DIR, 'api/types'), models: path.join(BASE_DIR, PATH_API_DIR_MODELS), mail: path.join(BASE_DIR, PATH_API_DIR_SRC, 'mail'), jobs: path.join(path.join(BASE_DIR, PATH_API_DIR_JOBS)), + distJobs: path.join(path.join(BASE_DIR, PATH_API_DIR_DIST, 'jobs')), jobsConfig: resolveFile(path.join(BASE_DIR, PATH_API_DIR_LIB, 'jobs')), + distJobsConfig: resolveFile( + path.join(BASE_DIR, PATH_API_DIR_DIST, 'lib', 'jobs'), + ), logger: resolveFile(path.join(BASE_DIR, PATH_API_DIR_LIB, 'logger')), }, From 5e79641427112e5ee78894491986b13db34877d2 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 25 Jul 2024 17:16:48 -0700 Subject: [PATCH 123/258] Update job setup template --- .../setup/jobs/templates/jobs.ts.template | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template b/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template index 8bfcdbf5341f..dce0f8f8ea2e 100644 --- a/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template +++ b/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template @@ -3,27 +3,26 @@ // See https://docs.redwoodjs.com/docs/background-jobs import { PrismaAdapter, RedwoodJob } from '@redwoodjs/jobs' -import type { AvailableJobs, WorkerConfig } from '@redwoodjs/jobs' +import type { AvailableJobs, BaseAdapter, WorkerConfig } from '@redwoodjs/jobs' import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' -const adapter = new PrismaAdapter({ db, logger }) +// `adapter`, `logger` and `workerConfig` exports are used by the job workers, +// can override on a per-worker basis: +// https://docs.redwoodjs.com/docs/background-jobs#worker-configuration +export const adapter: BaseAdapter = new PrismaAdapter({ db, logger }) +export { logger } -// Global config for all jobs, can override on a per-job basis -RedwoodJob.config({ adapter, logger }) - -// Global config for all workers, can override on a per-worker basis -// see https://docs.redwoodjs.com/docs/background-jobs#worker-configuration export const workerConfig: WorkerConfig = { - adapter, - logger, - maxAttempts: 24, - maxRuntime: 14_400, // 4 hours - sleepDelay: 5, - deleteFailedJobs: false, + maxAttempts: 10, + maxRuntime: 1000, // 4 hours + sleepDelay: 2, + deleteFailedJobs: true, } -// Export instances of all your jobs to make them easy to import and use: -// export const jobs = { sample: new SampleJob(), email: new EmailJob() } +// Global config for all jobs, can override on a per-job basis: +// https://docs.redwoodjs.com/docs/background-jobs#job-configuration +RedwoodJob.config({ adapter, logger }) + export const jobs: AvailableJobs = {} From 7efdbd23ef7ccbb8e39133de9f707687b83352a4 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 26 Jul 2024 15:25:37 -0700 Subject: [PATCH 124/258] Docs looking good --- docs/docs/background-jobs.md | 284 ++++++++++++++---- .../static/img/background-jobs/jobs-after.png | Bin 82168 -> 83662 bytes 2 files changed, 218 insertions(+), 66 deletions(-) diff --git a/docs/docs/background-jobs.md b/docs/docs/background-jobs.md index d81a27c9b415..5626bb7d3fbd 100644 --- a/docs/docs/background-jobs.md +++ b/docs/docs/background-jobs.md @@ -10,12 +10,28 @@ If we want the email to be send asynchonously, we can shuttle that process off i ![image](/img/background-jobs/jobs-after.png) -The user's response is returned much quicker, and the email is sent by another process which is connected to a user's session. All of the logic around sending the email is packaged up as a **job** and the **job server** is responsible for executing it. +The user's response is returned much quicker, and the email is sent by another process which is connected to a user's session. All of the logic around sending the email is packaged up as a **job** and a **job worker** is responsible for executing it. The job is completely self-contained and has everything it needs to perform its task. Let's see how Redwood implements this workflow. ## Overview & Quick Start +Before we get into anything with jobs specifically, we want to make sure `NODE_ENV` is set properly in your dev environment as this tells the job workers where to pull your jobs from: either `api/src/jobs` (when in development) or `api/dist/jobs` (when in production-like environments). + +In your `.env` or `.env.defaults` file, add an entry for `NODE_ENV`: + +```.env +NODE_ENV=development +``` + +If `NODE_ENV` is *not* set then the job runner will assume you're running a production-like environment and get jobs from `api/dist/jobs`. If you try to start a job worker and see this error: + +``` +JobsLibNotFoundError: api/src/lib/jobs.ts not found. Run `yarn rw setup jobs` to create this file and configure background jobs +``` + +Then `NODE_ENV` is probably not set! + ### Workflow There are three components to the background job system in Redwood: @@ -26,9 +42,9 @@ There are three components to the background job system in Redwood: **Scheduling** is the main interface to background jobs from within your application code. This is where you tell the system to run a job at some point in the future, whether that's "as soon as possible" or to delay for an amount of time first, or to run at a specific datetime in the future. Scheduling is handled by calling `performLater()` on an instance of your job. -**Storage** is necessary so that your jobs are decoupled from your application. By default jobs are stored in your database. This allows you to scale everything independently: the api server (which is scheduling jobs), the database (which is storing the jobs ready to be run), and the jobs runner (which is executing the jobs). +**Storage** is necessary so that your jobs are decoupled from your application. By default jobs are stored in your database. This allows you to scale everything independently: the api server (which is scheduling jobs), the database (which is storing the jobs ready to be run), and the job workers (which are executing the jobs). -**Execution** is handled by the job runner, which takes a job from storage, executes it, and then does something with the result, whether it was a success or failure. +**Execution** is handled by a job worker, which takes a job from storage, executes it, and then does something with the result, whether it was a success or failure. ### Installation @@ -38,14 +54,29 @@ To get started with jobs, run the setup command: yarn rw setup jobs ``` -This will add a new model to your Prisma schema, migrate the database, and create a configuration file at `api/src/lib/jobs.js` (or `.ts` for a Typescript project). Comments have been removed for brevity: +This will add a new model to your Prisma schema, and create a configuration file at `api/src/lib/jobs.js` (or `.ts` for a Typescript project). You'll need to run migrations in order to actually create the model in your database: + +```bash +yarn rw prisma migrate dev +``` + +Let's look at the config file. Comments have been removed for brevity: ```js import { PrismaAdapter, RedwoodJob } from '@redwoodjs/jobs' + import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' export const adapter = new PrismaAdapter({ db, logger }) +export { logger } + +export const workerConfig: WorkerConfig = { + maxAttempts: 24, + maxRuntime: 14_400, + sleepDelay: 5, + deleteFailedJobs: false, +} RedwoodJob.config({ adapter, logger }) @@ -62,7 +93,7 @@ We have a generator that creates a job in `api/src/jobs`: yarn rw g job SendWelcomeEmail ``` -Jobs are defined as a subclass of the `RedwoodJob` class and at a minimum must contain the function named `perform()` which contains the logic for your job. You can add as many additional functions you want to support the task your job is performing, but `perform()` is what's invoked by the **job runner** that we'll see later. +Jobs are defined as a subclass of the `RedwoodJob` class and at a minimum must contain the function named `perform()` which contains the logic for your job. You can add as many additional functions you want to support the task your job is performing, but `perform()` is what's invoked by the job worker that we'll see later. An example `SendWelcomeEmailJob` may look something like: @@ -75,7 +106,7 @@ export class SendWelcomeEmailJob extends RedwoodJob { perform(userId) { const user = await db.user.findUnique({ where: { id: userId } }) - await mailer.send(WelcomeEmail({ user }), { + mailer.send(WelcomeEmail({ user }), { to: user.email, subject: `Welcome to the site!`, }) @@ -84,24 +115,41 @@ export class SendWelcomeEmailJob extends RedwoodJob { } ``` -Note that `perform()` can take any arguments you want, but it's a best practice to keep them as simple as possible: a reference to this job and its arguments are stored in the database, so the list of arguments must be serializable to and from a string of JSON. Most jobs will probably act against data in your database, so it makes sense to have the arguments simply be the `id` of those database records. When the job executes it will look up the full database record and then proceed from there. +Note that `perform()` can take any arguments you want, but it's a best practice to keep them as simple as possible: a reference to this job and its arguments are stored in the database, so the list of arguments must be serializable to and from a string of JSON. + +:::info Keeping Arguments Simple -There are a couple different ways to invoke a job, but the simplest is to include an instance of your new job in the `jobs` object that's exported at the end of `api/src/lib/jobs.js` (note that the job generator will do this for you): +Most jobs will probably act against data in your database, so it makes sense to have the arguments simply be the `id` of those database records. When the job executes it will look up the full database record and then proceed from there. + +If it's likely that the data in the database will change before your job is actually run, you may want to include the original values as arguments to be sure your job is being performed with the correct data. + +::: + +In addition to creating the shell of the job itself, the job generator will add an instance of your job to the `jobs` exported in the jobs config file: ```js import { PrismaAdapter, RedwoodJob } from '@redwoodjs/jobs' + import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' // highlight-next-line import { SendWelcomeEmailJob } from 'src/jobs/SendWelcomeEmailJob' export const adapter = new PrismaAdapter({ db, logger }) +export { logger } + +export const workerConfig: WorkerConfig = { + maxAttempts: 24, + maxRuntime: 14_400, + sleepDelay: 5, + deleteFailedJobs: false, +} RedwoodJob.config({ adapter, logger }) export jobs = { // highlight-next-line - sendWelcomeEmailJob: new SendWelcomeEmailJob() + sendWelcomeEmail: new SendWelcomeEmailJob() } ``` @@ -109,26 +157,34 @@ This makes it easy to import and schedule your job as we'll see next. ### Scheduling a Job -All jobs expose a `performLater()` function (inherited from the parent `RedwoodJob` class). Simply call this function when you want to schedule your job. Carrying on with our example from above, let's schedule this job as part of the `createUser()` service that used to be sending the email directly: +All jobs expose a `performLater()` function (inherited from the parent `RedwoodJob` class). Simply call this function when you want to schedule your job. Carrying on with our example from above, let's schedule this job as part of the `createUser()` service that used to be sending the welcome email directly: ```js // highlight-next-line import { jobs } from 'api/src/lib/jobs' -export const createUser({ input }) { +export const createUser = async ({ input }) { const user = await db.user.create({ data: input }) // highlight-next-line - await jobs.sendWelcomeEmailJob.performLater(user.id) + await jobs.sendWelcomeEmail.performLater(user.id) return user } ``` -Or if you wanted to wait 5 minutes before sending the email you can set a `wait` time (number of seconds): +By default the job will run as soon as possible. If you wanted to wait five minutes before sending the email you can set a `wait` time to a number of seconds: ```js -await jobs.sendWelcomeEmailJob.set({ wait: 300 }).performLater(user.id) +await jobs.sendWelcomeEmail.set({ wait: 300 }).performLater(user.id) ``` +:::info Job Run Time Guarantees + +Job is never *guaranteed* to run at an exact time. The worker could be busy working on other jobs and can't get to yours just yet. The time you set for your job to run is the *soonest* it could possibly run. + +If you absolutely, positively need your job to run right now, take a look at the `performNow()` function instead of `performLater()`. The response from the server will wait until the job is complete, but you'll know for sure that it has run. + +::: + If we were to query the `BackgroundJob` table after the job has been scheduled you'd see a new row: ```json @@ -158,7 +214,7 @@ Because we're using the `PrismaAdapter` here all jobs are stored in the database ### Executing Jobs -In development you can start the job runner from the command line: +In development you can start a job worker via the **job runner** from the command line: ```bash yarn rw jobs work @@ -168,18 +224,22 @@ The runner is a sort of overseer that doesn't do any work itself, but spawns wor ![image](/img/background-jobs/jobs-terminal.png) -It checks the `BackgroundJob` table every few seconds for a new job and, if it finds one, locks it so that no other workers can have it, then calls `perform()` passing the arguments you gave to `performLater()`. +It checks the `BackgroundJob` table every few seconds for a new job and, if it finds one, locks it so that no other workers can have it, then calls your custom `perform()` function, passing it the arguments you gave to `performLater()` when you scheduled it. -If the job succeeds then it's removed the database. If the job fails, the job is un-locked in the database, the `runAt` is set to an incremental backoff time in the future, and `lastError` is updated with the error that occurred. The job will now be picked up in the future once the `runAt` time has passed and it'll try again. +If the job succeeds then it's removed the database (using the `PrismaAdapter`, other adapters behavior may vary). If the job fails, the job is un-locked in the database, the `runAt` is set to an incremental backoff time in the future, and `lastError` is updated with the error that occurred. The job will now be picked up in the future once the `runAt` time has passed and it'll try again. -## Detailed Usage +If that quick start covered your use case, great, you're done for now! Take a look at the [Deployment](#deployment) section when you're ready to go to production. -All jobs have some default configuration set for you if don't do anything different: +The rest of this doc describes more advanced usage, like: -* `queue` jobs can be in named queues and have dedicated workers that only pull jobs from that queue. This lets you scale not only your entire job runner independently of the rest of your app, but scale the individual queues as well. By default, all jobs will go in a queue named "default" if you don't override it (see Per-job Configuration below). -* `priority` within a single queue you can jobs that are more or less important. The workers will pull jobs off the queue with a higher priority before working on ones with a lower priority. The default priority is `50`. A lower number is *higher* in priority than a lower number. ie. the workers will work on a job with a priority of `1` before they work on one with a priority of `100`. -* `logger` jobs will log to the console if you don't tell them otherwise. The logger exported from `api/src/lib/logger.js` works well the job runner, so we recommend using that by setting it in `RedwoodJob.config()` or on a per-job basis. -* `adapter`: the adapter to use to store this jobs. There is no default adapter set for jobs, so you'll need to set this in `RedwoodJob.config()` or on a per-job basis. +* Assigning jobs to named **queues** +* Setting a **priority** so that some jobs always run before others +* Using different adapters and loggers on a per-job basis +* Starting more than one worker +* Having some workers focus on only certain queues +* Configuring individual workers to use different adapters +* Manually workers without the job runner monitoring them +* And more! ## RedwoodJob (Global) Configuration @@ -274,11 +334,19 @@ We'll see examples of configuring the individual jobs with an adapter and logger ::: -## Per-job Configuration +## Job Configuration + +All jobs have some default configuration set for you if don't do anything different: -If you don't do anything special, a job will inherit the adapter and logger you set with the call to `RedwoodJob.config()`. However, you can override these settings on a per-job basis: +* `queue` : jobs can be in named queues and have dedicated workers that only pull jobs from that queue. This lets you scale not only your entire job runner independently of the rest of your app, but scale the individual queues as well. By default, all jobs will go in a queue named "default" if you don't override it. +* `priority` : within a single queue you can jobs that are more or less important. The workers will pull jobs off the queue with a higher priority before working on ones with a lower priority. The default priority is `50`. A lower number is *higher* in priority than a lower number. ie. the workers will work on a job with a priority of `1` before they work on one with a priority of `100`. +* `logger` : jobs will log to the console if you don't tell them otherwise. The logger exported from `api/src/lib/logger.js` works well the job runner, so we recommend using that by setting it in `RedwoodJob.config()` or on a per-job basis. +* `adapter` : the adapter to use to store this jobs. There is no default adapter set for jobs, so you'll need to set this in `RedwoodJob.config()` or on a per-job basis. + +If you don't do anything special, a job will inherit the adapter and logger you set with the call to `RedwoodJob.config()`. However, you can override these settings on a per-job basis. You don't have to set all of them, you can use them in any combination you want: ```js +import { RedwoodJob, PrismaAdapter } from '@redwoodjs/jobs' import { db } from 'api/src/lib/db' import { emailLogger } from 'api/src/lib/logger' @@ -296,13 +364,6 @@ export const class SendWelcomeEmailJob extends RedwoodJob { } ``` -The variables you can set this way are: - -* `queue`: the named queue that jobs will be put in, defaults to `"default"` -* `priority`: an integer denoting the priority of this job (lower numbers are higher priority). Defaults to `50` -* `logger`: this will be made available as `this.logger` from within your job for internal logging. Defaults to `console` -* `adapter`: this is the adapter that's used when it comes time to schedule and store the job. There is no default, so this must be set either here or everywhere with `RedwoodJob.config({ adapter })` Redwood currently only ships with the `PrismaAdapter` - ## Adapter Configuration Adapters accept an object of options when they are initialized. @@ -316,18 +377,16 @@ const adapter = new PrismaAdapter({ db, model: 'BackgroundJob', logger: console, - maxAttemps: 24 }) ``` * `db`: **[required]** an instance of `PrismaClient` that the adapter will use to store, find and update the status of jobs. In most cases this will be the `db` variable exported from `api/src/lib/db.js`. This must be set in order for the adapter to be initialized! * `model`: the name of the model that was created to store jobs. This defaults to `BackgroundJob` * `logger`: events that occur within the adapter will be logged using this. This defaults to `console` but the `logger` exported from `api/src/lib/logger` works great, too. -* `maxAttempts` the number of times to allow a job to be retried before giving up. This defaults to `24`. If the `maxAttemps` is reached then the `failedAt` column is set in job's row in the database an no further attempts will be made to run it. ## Job Scheduling -The interface to schedule a job is very flexible. We have a recommended way, but there may be another that better suits your specific usecase. +The interface to schedule a job is fairly flexible: use the pattern you like best! By default the generators will assume you want to use the [Instance Invocation](#instance-invocation) pattern, but maybe the [Class Invocation](#class-invocation) speaks to your soul! ### Instance Invocation @@ -340,44 +399,76 @@ const job = new SendWelcomeEmailJob() job.set({ wait: 300 }).performLater() ``` -You can also do the setting separate from the scheduling: - -```js -const job = new SendWelcomeEmailJob() -job.set({ wait: 300 }) -job.performLater() -``` - -Using this syntax you can also set the queue and priority for only *this instance* of the job, overriding the configuration set on the job itself: +You can also set options when you create the instance. For example, if *every* invocation of this job should wait 5 minutes, no need to `set()` that each time, just initialize it with that option: ```js -// Job uses the `default` queue and has a priority of `50` -const job = new SendWelcomeEmailJob() - -job.set({ queue: 'email', priority: 1 }) -// or -job.queue = 'email' -job.priority = 1 +// api/src/lib/jobs.js +export const jobs = { + // highlight-next-line + sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }) +} -job.performLater() +// api/src/services/users/users.js +export const createUser = async ({ input }) => { + const user = await db.user.create({ data: input }) + // highlight-next-line + await jobs.sendWelcomeEmail.performLater() + return user +} ``` -You're using the instance invocation pattern when you add an instance of a job to the `jobs` export of `api/src/lib/jobs.js`: +`set()` will merge together with any options set when initialized, so you can, for example, override those settings on a one-off basis: ```js // api/src/lib/jobs.js export const jobs = { - sendWelcomeEmail: new SendWelcomeEmailJob() + // highlight-next-line + sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }) // 5 minutes } // api/src/services/users/users.js export const createUser = async ({ input }) => { const user = await db.user.create({ data: input }) - await jobs.sendWelcomeEmail.set({ wait: 300 }).performLater() + // highlight-next-line + await jobs.sendWelcomeEmail.set({ wait: 3600 }).performLater() // 1 hour return user } ``` +You can also do the setting separate from the scheduling: + +```js +const job = new SendWelcomeEmailJob() +job.set({ wait: 300 }) +// do something else... +job.performLater() +``` + +Once you have your instance you can inspect the options set on it: + +```js +const job = new SendWelcomeEmail() +// set by RedwoodJob.config or static properies +job.adapter // => PrismaAdapter instance +jog.logger // => logger instance + +// set via `set()` or provided during job instantiaion +job.queue // => 'default' +job.priority // => 50 +job.wait // => 300 +job.waitUntil // => null + +// computed internally +job.runAt // => 2025-07-27 12:35:00 UTC + // ^ the actual computed Date of now + `wait` +``` + +:::info + +You can't set these values directly, that's done through `set()`. Also, you can never set `runAt`, that's computed internally. If you want your job to run at a specific time you can use the `waitUntil` option, See [Scheduling Options](#scheduling-options) below. + +::: + ### Class Invocation You can schedule a job directly, without instantiating it first: @@ -392,7 +483,7 @@ AnnualReportGenerationJob .performLater() ``` -Using this syntax comes with a caveat: since no `RedwoodJob.config()` was called you would need to configure the adapter directly on `AnnualReportGenerationJob` (unless you were sure that `RedwoodJob.config()` was called somewhere before this code executes). See the note at the end of the [Exporting jobs](#exporting-jobs) section +Using this syntax comes with a caveat: since no `RedwoodJob.config()` was called you would need to configure the `adapter` and `logger` directly on `AnnualReportGenerationJob` via static properties (unless you were sure that `RedwoodJob.config()` was called somewhere before this code executes). See the note at the end of the [Exporting jobs](#exporting-jobs) section explaining this limitation. ### Scheduling Options @@ -417,9 +508,17 @@ These modes are ideal when you're creating a job and want to be sure it runs cor yarn rw jobs work ``` -This process will stay attached the console and continually look for new jobs and execute them as they are found. Pressing Ctrl-C to cancel the process (sending SIGINT) will start a graceful shutdown: the workers will complete any work they're in the middle of before exiting. To cancel immediately, hit Ctrl-C again (or send SIGTERM) and they'll stop in the middle of what they're doing. Note that this could leave locked jobs in the database, but they will be picked back up again if a new worker starts with the same name as the one that locked the process. +This process will stay attached the console and continually look for new jobs and execute them as they are found. The log level is set to `debug` by default so you'll see everything. Pressing Ctrl-C to cancel the process (sending SIGINT) will start a graceful shutdown: the workers will complete any work they're in the middle of before exiting. To cancel immediately, hit Ctrl-C again (or send SIGTERM) and they'll stop in the middle of what they're doing. Note that this could leave locked jobs in the database, but they will be picked back up again if a new worker starts with the same name as the one that locked the process. They'll also be picked up automatically after `maxRuntime` has expired, even if they are still locked. + +:::caution Long running jobs -To work on whatever outstanding jobs there are and then exit, use the `workoff` mode instead: +It's currently up to you to make sure your job completes before your `maxRuntime` limit is reached! NodeJS Promises are not truly cancelable: you can reject early, but any Promises that were started *inside* will continue running unless they are also early rejected, recursively forever. + +The only way to guarantee a job will completely stop no matter what is for your job to spawn an actual OS level process with a timeout that kills it after a certain amount of time. We may add this functionality natively to Jobs in the near future: let us know if you'd benefit from this being built in! + +::: + +To work on whatever outstanding jobs there are and then automaticaly exit use the `workoff` mode: ```bash yarn rw jobs workoff @@ -445,7 +544,7 @@ To stop the worker: yarn rw jobs stop ``` -And to restart: +Or to restart on that's already running: ```bash yarn rw jobs restart @@ -471,7 +570,7 @@ That starts 2 workers that only watch the `email` queue. To have multiple worker yarn rw jobs start -n default:2,email:4 ``` -2 workers watching the `default` queue and 4 watching `email`. +That starts 2 workers watching the `default` queue and 4 watching `email`. If you want to combine named queues and all queues, leave off the name: @@ -483,7 +582,7 @@ yarn rw jobs start -n :2,email:4 ### Stopping Multiple Workers -Make sure you pass the same flags to the `stop` process as the `start` so it knows which ones to stop. The same with the `restart` command. +Make sure you pass the same `-n` flag to the `stop` process as the `start` so it knows which ones to stop. The same with the `restart` command. ### Monitoring the Workers @@ -497,15 +596,68 @@ You can remove all jobs from storage with: yarn rw jobs clear ``` +## Deployment + +For many use cases you may simply be able to rely on the job runner to start your job workers, which will run forever: + +```bash +yarn rw jobs start +``` + +See the options available in [Job Runner > Production Modes](#production-modes) for additional flags. + +When you deploy new code you'll want to restart your runners to make sure they get the latest soruce files: + +```bash +yarn rw jobs restart +``` + +Using this utility, however, gives you nothing to monitor that your jobs workers are still running: the runner starts the required number of workers, detaches them, and then exits itself. Node processes are pretty robust, but by no means are they guaranteed to run forever with no problems. You could mistakenly release a bad job that has an infinite loop or even just a random gamma ray striking the RAM of the server could cause a panic and the process will be shut down. + +For maximum reliability you should take a look at the [Advanced Job Workers](#advanced-job-workers) section and manually start your workers this way, with a process monitor like [pm2](https://pm2.keymetrics.io/) or [nodemon](https://github.com/remy/nodemon) to watch and restart the workers if something unexpected happens. + +:::info + +Of course if you have a process monitor system watching your workers you'll to use the process monitor's version of the restart command each time you deploy! + +::: + +## Advanced Job Workers + +In the cases above, all workers will use the same `adapter`, `logger` and `workerConfig` exported from your jobs config file at `api/src/lib/jobs.js`. However, it's possible to have workers running completely different configurations. To do this, you'll need to start the worker directly, instead of having the build-in job runner start them for you. + +To start the worker yourself you'll run the `yarn rw-jobs-worker` command. As an example, to start a worker with the same configruation options that `yarn rw jobs work` would use, you'd run the following command: + +```bash +yarn rw-jobs-worker --id=0 --maxAttempts=24 --maxRuntime=14400 --sleepDelay 5 --adapter=adapter --logger=logger +``` + +The job runner sets the `--maxAttempts`, `--maxRuntime` and `--sleepDelay` flags based on the values in the `workerConfig` variable. The `--adapter` and `--logger` flags denote the name of the exported variables that the worker should load in order to set itself up to run jobs. + +### Flags + +* `--id` : a number identifier that's set as part of the process name. For example starting a worker with * `--id=0` and then inspecting your process list will show one running named `rw-job-worker.0` +* `--queue` : the named queue for the worker to focus on. Leaving this flag off defaults to watching all queues. This will also effect the process title name. Setting `--queue=email` would lead to process named * `rw-job-worker.email.0` (assuming `--id=0`) +* `--workoff` : if this flag is present then the worker will terminate itself after the queue it is watching is empty +* `--clear` : if this flag is present the worker will call the `clear()` function on the adapter (which should remove all jobs in storage) and then exit +* `--maxAttempts` : The max number of times to retry a job before considering it failed, and will not be retried. Defaults to `24`. +* `--maxRuntime` : The max amount of time, in seconds, a job should run before being eligible to be picked up by another worker instead, effectively starting over. Defaults to `14400` which is 4 hours. +* `--sleepDelay` : How long to wait, in seconds, before querying the adapter for another available job to run. Careful not to DDoS your job storage system by setting this too low! +* `--deleteFailedJobs` : If `maxAttempts` is reached, what to do with the job. Defaults to `false` meaning the job will remain in the queue, but the workers will not pick it up to work on again. Useful for debugging why a job may have failed. +* `--adapter` : The name of the variable exported from `api/src/lib/jobs.js` to use as the adapter for this worker instance. Defalts to `adapter`. The worker will error on startup if this variable is not exported by your jobs config file. +* `--logger` : The name of the variable exported from `api/src/lib/jobs.js` to us as the logger for this worker instance. Defaults to `logger` but will fall back to `console` if no `logger` is exported. + ## Creating Your Own Adapter -TODO +We'd love the community to contribue adapters for Redwood Job! Take a look at the source for `BaseAdapter` for what's absolutely required, and then the source for `PrismaAdapter` to see a concrete implementation. + +The general gist of the required functions: -* `find()` should find a job to be run, lock it and return it (minimum return of `handler` and `args`) +* `find()` should find a job to be run, lock it and return it (minimum return of and object containing `handler` and `args` properties) * `schedule()` accepts `handler`, `args`, `runAt`, `queue` and `priority` and should store the job -* `success()` accepts the job returned from `find()` and does whatever success means (delete) -* `failure()` accepts the job returned from `find()` and does whatever failure means (unlock and reschedule) -* `clear()` removes all jobs +* `success()` accepts the same job object returned from `find()` and does whatever success means (delete the job from the queue, most likely) +* `failure()` accepts the same job object returned from `find()` and does whatever failure means (unlock the job and reschedule a time for it to run again in the future, or give up if `maxAttempts` is reached) +* `clear()` remove all jobs from the queue (mostly used in development) ## The Future diff --git a/docs/static/img/background-jobs/jobs-after.png b/docs/static/img/background-jobs/jobs-after.png index de1d540e49c397f53617317e87ef2299d64a7e76..9884164c18191837f366faf7e14e4e15d2b523eb 100644 GIT binary patch literal 83662 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 TB&i8J;DKvuT32#aO#J>AI|Qy@ literal 82168 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

ecw80~OjLskx^0+1PQxt)=ete7(M6i3P0Eudc; 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 From 20edfeb7089b66ca2fbece3019d04427a792522e Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 26 Jul 2024 15:25:44 -0700 Subject: [PATCH 125/258] Update setup template --- .../src/commands/setup/jobs/templates/jobs.ts.template | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template b/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template index dce0f8f8ea2e..053aa33ef28c 100644 --- a/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template +++ b/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template @@ -15,10 +15,10 @@ export const adapter: BaseAdapter = new PrismaAdapter({ db, logger }) export { logger } export const workerConfig: WorkerConfig = { - maxAttempts: 10, - maxRuntime: 1000, // 4 hours - sleepDelay: 2, - deleteFailedJobs: true, + maxAttempts: 24, + maxRuntime: 14_400, // 4 hours + sleepDelay: 5, + deleteFailedJobs: false, } // Global config for all jobs, can override on a per-job basis: From 4656eb00c021393921118d7fe107bd0e520177a6 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 26 Jul 2024 15:26:08 -0700 Subject: [PATCH 126/258] Have loaders look at NODE_ENV as to whether to load from src or dist --- packages/jobs/src/core/loaders.ts | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/jobs/src/core/loaders.ts b/packages/jobs/src/core/loaders.ts index 0c13a5fa53c9..a43b1488c964 100644 --- a/packages/jobs/src/core/loaders.ts +++ b/packages/jobs/src/core/loaders.ts @@ -8,7 +8,11 @@ import { getPaths } from '@redwoodjs/project-config' import { JobsLibNotFoundError, JobNotFoundError } from './errors' -if (process.env.NODE_ENV !== 'production') { +const DEV_ENVIRONMENTS = ['development', 'test'] + +const isDev = DEV_ENVIRONMENTS.includes(process.env.NODE_ENV || '') + +if (isDev) { registerApiSideBabelHook() } @@ -19,12 +23,9 @@ export function makeFilePath(path: string) { // Loads the named export from the app's jobs config in api/src/lib/jobs.{js,ts} // to configure the worker, defaults to `workerConfig` export const loadJobsConfig = async () => { - const jobsConfigPath = - process.env.NODE_ENV === 'production' - ? getPaths().api.distJobsConfig - : getPaths().api.jobsConfig - - console.info('loading config from', jobsConfigPath) + const jobsConfigPath = isDev + ? getPaths().api.jobsConfig + : getPaths().api.distJobsConfig if (jobsConfigPath) { return require(jobsConfigPath) @@ -35,12 +36,7 @@ export const loadJobsConfig = async () => { // Loads a job from the app's filesystem in api/src/jobs export const loadJob = async (name: string) => { - const baseJobsPath = - process.env.NODE_ENV === 'production' - ? getPaths().api.distJobs - : getPaths().api.jobs - - console.info('loading jobs from', baseJobsPath) + const baseJobsPath = isDev ? getPaths().api.jobs : getPaths().api.distJobs // Specifying {js,ts} extensions, so we don't accidentally try to load .json // files or similar From e31c66aff60eec3926770836b97a07b39ab29c38 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 26 Jul 2024 15:26:23 -0700 Subject: [PATCH 127/258] Add NODE_ENV to .env.defaults when you setup jobs? --- .../src/commands/setup/jobs/jobsHandler.js | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/cli/src/commands/setup/jobs/jobsHandler.js b/packages/cli/src/commands/setup/jobs/jobsHandler.js index 0b0433f825d0..ca3917d51fb3 100644 --- a/packages/cli/src/commands/setup/jobs/jobsHandler.js +++ b/packages/cli/src/commands/setup/jobs/jobsHandler.js @@ -97,6 +97,29 @@ const tasks = async ({ force }) => { }) }, }, + { + title: 'Adding NODE_ENV var...', + task: () => { + let envFile = fs + .readFileSync( + path.resolve(getPaths().base, '.env.defaults'), + 'utf-8', + ) + .toString() + + envFile = envFile + '\nNODE_ENV=development\n' + + writeFile(path.resolve(getPaths().base, '.env.defaults'), envFile, { + overwriteExisting: true, + }) + }, + skip: () => { + if (process.env.NODE_ENV) { + return 'NODE_ENV already set, skipping' + } + }, + }, + { title: 'One more thing...', task: (_ctx, task) => { From 2f81947fad74ed980f21a638c9f2d85d8b097eb2 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 26 Jul 2024 15:45:45 -0700 Subject: [PATCH 128/258] Send arguments as an array --- packages/cli/src/commands/generate/job/job.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/generate/job/job.js b/packages/cli/src/commands/generate/job/job.js index 06772d5722f3..8cd736a313f2 100644 --- a/packages/cli/src/commands/generate/job/job.js +++ b/packages/cli/src/commands/generate/job/job.js @@ -201,9 +201,13 @@ export const handler = async ({ name, force, ...rest }) => { { title: 'Cleaning up...', task: () => { - execa.commandSync( - `yarn eslint --fix --config ${getPaths().base}/node_modules/@redwoodjs/eslint-config/shared.js ${getPaths().api.jobsConfig}`, - ) + execa.commandSync('yarn', [ + 'eslint', + '--fix', + '--config', + `${getPaths().base}/node_modules/@redwoodjs/eslint-config/shared.js`, + `${getPaths().api.jobsConfig}`, + ]) }, }, ], From f3d15212759158c38863fa0bd5f154e1295bd54d Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 26 Jul 2024 15:47:51 -0700 Subject: [PATCH 129/258] yarn install --- yarn.lock | 259 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 250 insertions(+), 9 deletions(-) diff --git a/yarn.lock b/yarn.lock index 413a995bba4e..98a393c48c0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11477,6 +11477,17 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:1.6.0": + version: 1.6.0 + resolution: "@vitest/expect@npm:1.6.0" + dependencies: + "@vitest/spy": "npm:1.6.0" + "@vitest/utils": "npm:1.6.0" + chai: "npm:^4.3.10" + checksum: 10c0/a4351f912a70543e04960f5694f1f1ac95f71a856a46e87bba27d3eb72a08c5d11d35021cbdc6077452a152e7d93723fc804bba76c2cc53c8896b7789caadae3 + languageName: node + linkType: hard + "@vitest/expect@npm:2.0.4": version: 2.0.4 resolution: "@vitest/expect@npm:2.0.4" @@ -11498,6 +11509,17 @@ __metadata: languageName: node linkType: hard +"@vitest/runner@npm:1.6.0": + version: 1.6.0 + resolution: "@vitest/runner@npm:1.6.0" + dependencies: + "@vitest/utils": "npm:1.6.0" + p-limit: "npm:^5.0.0" + pathe: "npm:^1.1.1" + checksum: 10c0/27d67fa51f40effe0e41ee5f26563c12c0ef9a96161f806036f02ea5eb9980c5cdf305a70673942e7a1e3d472d4d7feb40093ae93024ef1ccc40637fc65b1d2f + languageName: node + linkType: hard + "@vitest/runner@npm:2.0.4": version: 2.0.4 resolution: "@vitest/runner@npm:2.0.4" @@ -11508,6 +11530,17 @@ __metadata: languageName: node linkType: hard +"@vitest/snapshot@npm:1.6.0": + version: 1.6.0 + resolution: "@vitest/snapshot@npm:1.6.0" + dependencies: + magic-string: "npm:^0.30.5" + pathe: "npm:^1.1.1" + pretty-format: "npm:^29.7.0" + checksum: 10c0/be027fd268d524589ff50c5fad7b4faa1ac5742b59ac6c1dc6f5a3930aad553560e6d8775e90ac4dfae4be746fc732a6f134ba95606a1519707ce70db3a772a5 + languageName: node + linkType: hard + "@vitest/snapshot@npm:2.0.4": version: 2.0.4 resolution: "@vitest/snapshot@npm:2.0.4" @@ -11519,6 +11552,15 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:1.6.0": + version: 1.6.0 + resolution: "@vitest/spy@npm:1.6.0" + dependencies: + tinyspy: "npm:^2.2.0" + checksum: 10c0/df66ea6632b44fb76ef6a65c1abbace13d883703aff37cd6d062add6dcd1b883f19ce733af8e0f7feb185b61600c6eb4042a518e4fb66323d0690ec357f9401c + languageName: node + linkType: hard + "@vitest/spy@npm:2.0.4": version: 2.0.4 resolution: "@vitest/spy@npm:2.0.4" @@ -11528,6 +11570,18 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:1.6.0": + version: 1.6.0 + resolution: "@vitest/utils@npm:1.6.0" + dependencies: + diff-sequences: "npm:^29.6.3" + estree-walker: "npm:^3.0.3" + loupe: "npm:^2.3.7" + pretty-format: "npm:^29.7.0" + checksum: 10c0/8b0d19835866455eb0b02b31c5ca3d8ad45f41a24e4c7e1f064b480f6b2804dc895a70af332f14c11ed89581011b92b179718523f55f5b14787285a0321b1301 + languageName: node + linkType: hard + "@vitest/utils@npm:2.0.4": version: 2.0.4 resolution: "@vitest/utils@npm:2.0.4" @@ -11845,7 +11899,7 @@ __metadata: languageName: node linkType: hard -"acorn-walk@npm:8.3.3, acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1": +"acorn-walk@npm:8.3.3, acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1, acorn-walk@npm:^8.3.2": version: 8.3.3 resolution: "acorn-walk@npm:8.3.3" dependencies: @@ -12486,6 +12540,13 @@ __metadata: languageName: node linkType: hard +"assertion-error@npm:^1.1.0": + version: 1.1.0 + resolution: "assertion-error@npm:1.1.0" + checksum: 10c0/25456b2aa333250f01143968e02e4884a34588a8538fbbf65c91a637f1dbfb8069249133cd2f4e530f10f624d206a664e7df30207830b659e9f5298b00a4099b + languageName: node + linkType: hard + "assertion-error@npm:^2.0.1": version: 2.0.1 resolution: "assertion-error@npm:2.0.1" @@ -13552,6 +13613,21 @@ __metadata: languageName: node linkType: hard +"chai@npm:^4.3.10": + version: 4.5.0 + resolution: "chai@npm:4.5.0" + dependencies: + assertion-error: "npm:^1.1.0" + check-error: "npm:^1.0.3" + deep-eql: "npm:^4.1.3" + get-func-name: "npm:^2.0.2" + loupe: "npm:^2.3.6" + pathval: "npm:^1.1.1" + type-detect: "npm:^4.1.0" + checksum: 10c0/b8cb596bd1aece1aec659e41a6e479290c7d9bee5b3ad63d2898ad230064e5b47889a3bc367b20100a0853b62e026e2dc514acf25a3c9385f936aa3614d4ab4d + languageName: node + linkType: hard + "chai@npm:^5.1.1": version: 5.1.1 resolution: "chai@npm:5.1.1" @@ -13697,6 +13773,15 @@ __metadata: languageName: node linkType: hard +"check-error@npm:^1.0.3": + version: 1.0.3 + resolution: "check-error@npm:1.0.3" + dependencies: + get-func-name: "npm:^2.0.2" + checksum: 10c0/94aa37a7315c0e8a83d0112b5bfb5a8624f7f0f81057c73e4707729cdd8077166c6aefb3d8e2b92c63ee130d4a2ff94bad46d547e12f3238cc1d78342a973841 + languageName: node + linkType: hard + "check-error@npm:^2.1.1": version: 2.1.1 resolution: "check-error@npm:2.1.1" @@ -15101,6 +15186,15 @@ __metadata: languageName: node linkType: hard +"deep-eql@npm:^4.1.3": + version: 4.1.4 + resolution: "deep-eql@npm:4.1.4" + dependencies: + type-detect: "npm:^4.0.0" + checksum: 10c0/264e0613493b43552fc908f4ff87b8b445c0e6e075656649600e1b8a17a57ee03e960156fce7177646e4d2ddaf8e5ee616d76bd79929ff593e5c79e4e5e6c517 + languageName: node + linkType: hard + "deep-eql@npm:^5.0.1": version: 5.0.2 resolution: "deep-eql@npm:5.0.2" @@ -16258,7 +16352,7 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.21.3, esbuild@npm:~0.21.5": +"esbuild@npm:^0.21.3, esbuild@npm:~0.21.4, esbuild@npm:~0.21.5": version: 0.21.5 resolution: "esbuild@npm:0.21.5" dependencies: @@ -17857,7 +17951,7 @@ __metadata: languageName: node linkType: hard -"get-func-name@npm:^2.0.1": +"get-func-name@npm:^2.0.1, get-func-name@npm:^2.0.2": version: 2.0.2 resolution: "get-func-name@npm:2.0.2" checksum: 10c0/89830fd07623fa73429a711b9daecdb304386d237c71268007f788f113505ef1d4cc2d0b9680e072c5082490aec9df5d7758bf5ac6f1c37062855e8e3dc0b9df @@ -21739,6 +21833,15 @@ __metadata: languageName: node linkType: hard +"loupe@npm:^2.3.6, loupe@npm:^2.3.7": + version: 2.3.7 + resolution: "loupe@npm:2.3.7" + dependencies: + get-func-name: "npm:^2.0.1" + checksum: 10c0/71a781c8fc21527b99ed1062043f1f2bb30bdaf54fa4cf92463427e1718bc6567af2988300bc243c1f276e4f0876f29e3cbf7b58106fdc186915687456ce5bf4 + languageName: node + linkType: hard + "loupe@npm:^3.1.0, loupe@npm:^3.1.1": version: 3.1.1 resolution: "loupe@npm:3.1.1" @@ -21855,7 +21958,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:0.30.10, magic-string@npm:^0.30.0, magic-string@npm:^0.30.10, magic-string@npm:^0.30.3": +"magic-string@npm:0.30.10, magic-string@npm:^0.30.0, magic-string@npm:^0.30.10, magic-string@npm:^0.30.3, magic-string@npm:^0.30.5": version: 0.30.10 resolution: "magic-string@npm:0.30.10" dependencies: @@ -24048,6 +24151,15 @@ __metadata: languageName: node linkType: hard +"p-limit@npm:^5.0.0": + version: 5.0.0 + resolution: "p-limit@npm:5.0.0" + dependencies: + yocto-queue: "npm:^1.0.0" + checksum: 10c0/574e93b8895a26e8485eb1df7c4b58a1a6e8d8ae41b1750cc2cc440922b3d306044fc6e9a7f74578a883d46802d9db72b30f2e612690fcef838c173261b1ed83 + languageName: node + linkType: hard + "p-locate@npm:^2.0.0": version: 2.0.0 resolution: "p-locate@npm:2.0.0" @@ -24528,13 +24640,20 @@ __metadata: languageName: node linkType: hard -"pathe@npm:^1.1.0, pathe@npm:^1.1.2": +"pathe@npm:^1.1.0, pathe@npm:^1.1.1, pathe@npm:^1.1.2": version: 1.1.2 resolution: "pathe@npm:1.1.2" checksum: 10c0/64ee0a4e587fb0f208d9777a6c56e4f9050039268faaaaecd50e959ef01bf847b7872785c36483fa5cdcdbdfdb31fef2ff222684d4fc21c330ab60395c681897 languageName: node linkType: hard +"pathval@npm:^1.1.1": + version: 1.1.1 + resolution: "pathval@npm:1.1.1" + checksum: 10c0/f63e1bc1b33593cdf094ed6ff5c49c1c0dc5dc20a646ca9725cc7fe7cd9995002d51d5685b9b2ec6814342935748b711bafa840f84c0bb04e38ff40a335c94dc + languageName: node + linkType: hard + "pathval@npm:^2.0.0": version: 2.0.0 resolution: "pathval@npm:2.0.0" @@ -27388,7 +27507,7 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.7.0": +"std-env@npm:^3.5.0, std-env@npm:^3.7.0": version: 3.7.0 resolution: "std-env@npm:3.7.0" checksum: 10c0/60edf2d130a4feb7002974af3d5a5f3343558d1ccf8d9b9934d225c638606884db4a20d2fe6440a09605bca282af6b042ae8070a10490c0800d69e82e478f41e @@ -27769,7 +27888,7 @@ __metadata: languageName: node linkType: hard -"strip-literal@npm:^2.1.0": +"strip-literal@npm:^2.0.0, strip-literal@npm:^2.1.0": version: 2.1.0 resolution: "strip-literal@npm:2.1.0" dependencies: @@ -28193,13 +28312,20 @@ __metadata: languageName: node linkType: hard -"tinybench@npm:^2.8.0": +"tinybench@npm:^2.5.1, tinybench@npm:^2.8.0": version: 2.8.0 resolution: "tinybench@npm:2.8.0" checksum: 10c0/5a9a642351fa3e4955e0cbf38f5674be5f3ba6730fd872fd23a5c953ad6c914234d5aba6ea41ef88820180a81829ceece5bd8d3967c490c5171bca1141c2f24d languageName: node linkType: hard +"tinypool@npm:^0.8.3": + version: 0.8.4 + resolution: "tinypool@npm:0.8.4" + checksum: 10c0/779c790adcb0316a45359652f4b025958c1dff5a82460fe49f553c864309b12ad732c8288be52f852973bc76317f5e7b3598878aee0beb8a33322c0e72c4a66c + languageName: node + linkType: hard + "tinypool@npm:^1.0.0": version: 1.0.0 resolution: "tinypool@npm:1.0.0" @@ -28214,6 +28340,13 @@ __metadata: languageName: node linkType: hard +"tinyspy@npm:^2.2.0": + version: 2.2.1 + resolution: "tinyspy@npm:2.2.1" + checksum: 10c0/0b4cfd07c09871e12c592dfa7b91528124dc49a4766a0b23350638c62e6a483d5a2a667de7e6282246c0d4f09996482ddaacbd01f0c05b7ed7e0f79d32409bdc + languageName: node + linkType: hard + "tinyspy@npm:^3.0.0": version: 3.0.0 resolution: "tinyspy@npm:3.0.0" @@ -28582,6 +28715,22 @@ __metadata: languageName: node linkType: hard +"tsx@npm:4.15.6": + version: 4.15.6 + resolution: "tsx@npm:4.15.6" + dependencies: + esbuild: "npm:~0.21.4" + fsevents: "npm:~2.3.3" + get-tsconfig: "npm:^4.7.5" + dependenciesMeta: + fsevents: + optional: true + bin: + tsx: dist/cli.mjs + checksum: 10c0/c44e489d35b8b4795d68164572eb9e322a707290aa0786c2aac0f5c7782a884dfec38d557d74471b981a8314b2c7f6612078451d0429db028a23cb54a37e83a0 + languageName: node + linkType: hard + "tsx@npm:4.16.2": version: 4.16.2 resolution: "tsx@npm:4.16.2" @@ -28655,6 +28804,13 @@ __metadata: languageName: node linkType: hard +"type-detect@npm:^4.0.0, type-detect@npm:^4.1.0": + version: 4.1.0 + resolution: "type-detect@npm:4.1.0" + checksum: 10c0/df8157ca3f5d311edc22885abc134e18ff8ffbc93d6a9848af5b682730ca6a5a44499259750197250479c5331a8a75b5537529df5ec410622041650a7f293e2a + languageName: node + linkType: hard + "type-fest@npm:4.23.0": version: 4.23.0 resolution: "type-fest@npm:4.23.0" @@ -28804,6 +28960,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:5.4.5": + version: 5.4.5 + resolution: "typescript@npm:5.4.5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/2954022ada340fd3d6a9e2b8e534f65d57c92d5f3989a263754a78aba549f7e6529acc1921913560a4b816c46dce7df4a4d29f9f11a3dc0d4213bb76d043251e + languageName: node + linkType: hard + "typescript@npm:5.5.4, typescript@npm:>=3 < 6": version: 5.5.4 resolution: "typescript@npm:5.5.4" @@ -28824,6 +28990,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A5.4.5#optional!builtin": + version: 5.4.5 + resolution: "typescript@patch:typescript@npm%3A5.4.5#optional!builtin::version=5.4.5&hash=5adc0c" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/db2ad2a16ca829f50427eeb1da155e7a45e598eec7b086d8b4e8ba44e5a235f758e606d681c66992230d3fc3b8995865e5fd0b22a2c95486d0b3200f83072ec9 + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A5.5.4#optional!builtin, typescript@patch:typescript@npm%3A>=3 < 6#optional!builtin": version: 5.5.4 resolution: "typescript@patch:typescript@npm%3A5.5.4#optional!builtin::version=5.5.4&hash=379a07" @@ -29408,6 +29584,21 @@ __metadata: languageName: node linkType: hard +"vite-node@npm:1.6.0": + version: 1.6.0 + resolution: "vite-node@npm:1.6.0" + dependencies: + cac: "npm:^6.7.14" + debug: "npm:^4.3.4" + pathe: "npm:^1.1.1" + picocolors: "npm:^1.0.0" + vite: "npm:^5.0.0" + bin: + vite-node: vite-node.mjs + checksum: 10c0/0807e6501ac7763e0efa2b4bd484ce99fb207e92c98624c9f8999d1f6727ac026e457994260fa7fdb7060d87546d197081e46a705d05b0136a38b6f03715cbc2 + languageName: node + linkType: hard + "vite-node@npm:2.0.4": version: 2.0.4 resolution: "vite-node@npm:2.0.4" @@ -29488,6 +29679,56 @@ __metadata: languageName: node linkType: hard +"vitest@npm:1.6.0": + version: 1.6.0 + resolution: "vitest@npm:1.6.0" + dependencies: + "@vitest/expect": "npm:1.6.0" + "@vitest/runner": "npm:1.6.0" + "@vitest/snapshot": "npm:1.6.0" + "@vitest/spy": "npm:1.6.0" + "@vitest/utils": "npm:1.6.0" + acorn-walk: "npm:^8.3.2" + chai: "npm:^4.3.10" + debug: "npm:^4.3.4" + execa: "npm:^8.0.1" + local-pkg: "npm:^0.5.0" + magic-string: "npm:^0.30.5" + pathe: "npm:^1.1.1" + picocolors: "npm:^1.0.0" + std-env: "npm:^3.5.0" + strip-literal: "npm:^2.0.0" + tinybench: "npm:^2.5.1" + tinypool: "npm:^0.8.3" + vite: "npm:^5.0.0" + vite-node: "npm:1.6.0" + why-is-node-running: "npm:^2.2.2" + peerDependencies: + "@edge-runtime/vm": "*" + "@types/node": ^18.0.0 || >=20.0.0 + "@vitest/browser": 1.6.0 + "@vitest/ui": 1.6.0 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10c0/065da5b8ead51eb174d93dac0cd50042ca9539856dc25e340ea905d668c41961f7e00df3e388e6c76125b2c22091db2e8465f993d0f6944daf9598d549e562e7 + languageName: node + linkType: hard + "vitest@npm:2.0.4": version: 2.0.4 resolution: "vitest@npm:2.0.4" @@ -29897,7 +30138,7 @@ __metadata: languageName: node linkType: hard -"why-is-node-running@npm:^2.3.0": +"why-is-node-running@npm:^2.2.2, why-is-node-running@npm:^2.3.0": version: 2.3.0 resolution: "why-is-node-running@npm:2.3.0" dependencies: From 942eba2bffe9e5af0e5ed48c7a2e2bee924113e2 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 26 Jul 2024 15:54:35 -0700 Subject: [PATCH 130/258] Update dependency versions --- packages/jobs/package.json | 6 +- yarn.lock | 265 ++----------------------------------- 2 files changed, 15 insertions(+), 256 deletions(-) diff --git a/packages/jobs/package.json b/packages/jobs/package.json index 8367c05f6a7f..233beb7b0c9b 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -30,9 +30,9 @@ }, "devDependencies": { "@redwoodjs/project-config": "workspace:*", - "tsx": "4.15.6", - "typescript": "5.4.5", - "vitest": "1.6.0" + "tsx": "4.16.2", + "typescript": "5.5.4", + "vitest": "2.0.4" }, "gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1" } diff --git a/yarn.lock b/yarn.lock index 98a393c48c0c..841707a62532 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8319,9 +8319,9 @@ __metadata: dependencies: "@redwoodjs/project-config": "workspace:*" fast-glob: "npm:3.3.2" - tsx: "npm:4.15.6" - typescript: "npm:5.4.5" - vitest: "npm:1.6.0" + tsx: "npm:4.16.2" + typescript: "npm:5.5.4" + vitest: "npm:2.0.4" bin: rw-jobs: ./dist/bins/rw-jobs.js rw-jobs-worker: ./dist/bins/rw-jobs-worker.js @@ -11477,17 +11477,6 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/expect@npm:1.6.0" - dependencies: - "@vitest/spy": "npm:1.6.0" - "@vitest/utils": "npm:1.6.0" - chai: "npm:^4.3.10" - checksum: 10c0/a4351f912a70543e04960f5694f1f1ac95f71a856a46e87bba27d3eb72a08c5d11d35021cbdc6077452a152e7d93723fc804bba76c2cc53c8896b7789caadae3 - languageName: node - linkType: hard - "@vitest/expect@npm:2.0.4": version: 2.0.4 resolution: "@vitest/expect@npm:2.0.4" @@ -11509,17 +11498,6 @@ __metadata: languageName: node linkType: hard -"@vitest/runner@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/runner@npm:1.6.0" - dependencies: - "@vitest/utils": "npm:1.6.0" - p-limit: "npm:^5.0.0" - pathe: "npm:^1.1.1" - checksum: 10c0/27d67fa51f40effe0e41ee5f26563c12c0ef9a96161f806036f02ea5eb9980c5cdf305a70673942e7a1e3d472d4d7feb40093ae93024ef1ccc40637fc65b1d2f - languageName: node - linkType: hard - "@vitest/runner@npm:2.0.4": version: 2.0.4 resolution: "@vitest/runner@npm:2.0.4" @@ -11530,17 +11508,6 @@ __metadata: languageName: node linkType: hard -"@vitest/snapshot@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/snapshot@npm:1.6.0" - dependencies: - magic-string: "npm:^0.30.5" - pathe: "npm:^1.1.1" - pretty-format: "npm:^29.7.0" - checksum: 10c0/be027fd268d524589ff50c5fad7b4faa1ac5742b59ac6c1dc6f5a3930aad553560e6d8775e90ac4dfae4be746fc732a6f134ba95606a1519707ce70db3a772a5 - languageName: node - linkType: hard - "@vitest/snapshot@npm:2.0.4": version: 2.0.4 resolution: "@vitest/snapshot@npm:2.0.4" @@ -11552,15 +11519,6 @@ __metadata: languageName: node linkType: hard -"@vitest/spy@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/spy@npm:1.6.0" - dependencies: - tinyspy: "npm:^2.2.0" - checksum: 10c0/df66ea6632b44fb76ef6a65c1abbace13d883703aff37cd6d062add6dcd1b883f19ce733af8e0f7feb185b61600c6eb4042a518e4fb66323d0690ec357f9401c - languageName: node - linkType: hard - "@vitest/spy@npm:2.0.4": version: 2.0.4 resolution: "@vitest/spy@npm:2.0.4" @@ -11570,18 +11528,6 @@ __metadata: languageName: node linkType: hard -"@vitest/utils@npm:1.6.0": - version: 1.6.0 - resolution: "@vitest/utils@npm:1.6.0" - dependencies: - diff-sequences: "npm:^29.6.3" - estree-walker: "npm:^3.0.3" - loupe: "npm:^2.3.7" - pretty-format: "npm:^29.7.0" - checksum: 10c0/8b0d19835866455eb0b02b31c5ca3d8ad45f41a24e4c7e1f064b480f6b2804dc895a70af332f14c11ed89581011b92b179718523f55f5b14787285a0321b1301 - languageName: node - linkType: hard - "@vitest/utils@npm:2.0.4": version: 2.0.4 resolution: "@vitest/utils@npm:2.0.4" @@ -11899,7 +11845,7 @@ __metadata: languageName: node linkType: hard -"acorn-walk@npm:8.3.3, acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1, acorn-walk@npm:^8.3.2": +"acorn-walk@npm:8.3.3, acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1": version: 8.3.3 resolution: "acorn-walk@npm:8.3.3" dependencies: @@ -12540,13 +12486,6 @@ __metadata: languageName: node linkType: hard -"assertion-error@npm:^1.1.0": - version: 1.1.0 - resolution: "assertion-error@npm:1.1.0" - checksum: 10c0/25456b2aa333250f01143968e02e4884a34588a8538fbbf65c91a637f1dbfb8069249133cd2f4e530f10f624d206a664e7df30207830b659e9f5298b00a4099b - languageName: node - linkType: hard - "assertion-error@npm:^2.0.1": version: 2.0.1 resolution: "assertion-error@npm:2.0.1" @@ -13613,21 +13552,6 @@ __metadata: languageName: node linkType: hard -"chai@npm:^4.3.10": - version: 4.5.0 - resolution: "chai@npm:4.5.0" - dependencies: - assertion-error: "npm:^1.1.0" - check-error: "npm:^1.0.3" - deep-eql: "npm:^4.1.3" - get-func-name: "npm:^2.0.2" - loupe: "npm:^2.3.6" - pathval: "npm:^1.1.1" - type-detect: "npm:^4.1.0" - checksum: 10c0/b8cb596bd1aece1aec659e41a6e479290c7d9bee5b3ad63d2898ad230064e5b47889a3bc367b20100a0853b62e026e2dc514acf25a3c9385f936aa3614d4ab4d - languageName: node - linkType: hard - "chai@npm:^5.1.1": version: 5.1.1 resolution: "chai@npm:5.1.1" @@ -13773,15 +13697,6 @@ __metadata: languageName: node linkType: hard -"check-error@npm:^1.0.3": - version: 1.0.3 - resolution: "check-error@npm:1.0.3" - dependencies: - get-func-name: "npm:^2.0.2" - checksum: 10c0/94aa37a7315c0e8a83d0112b5bfb5a8624f7f0f81057c73e4707729cdd8077166c6aefb3d8e2b92c63ee130d4a2ff94bad46d547e12f3238cc1d78342a973841 - languageName: node - linkType: hard - "check-error@npm:^2.1.1": version: 2.1.1 resolution: "check-error@npm:2.1.1" @@ -15186,15 +15101,6 @@ __metadata: languageName: node linkType: hard -"deep-eql@npm:^4.1.3": - version: 4.1.4 - resolution: "deep-eql@npm:4.1.4" - dependencies: - type-detect: "npm:^4.0.0" - checksum: 10c0/264e0613493b43552fc908f4ff87b8b445c0e6e075656649600e1b8a17a57ee03e960156fce7177646e4d2ddaf8e5ee616d76bd79929ff593e5c79e4e5e6c517 - languageName: node - linkType: hard - "deep-eql@npm:^5.0.1": version: 5.0.2 resolution: "deep-eql@npm:5.0.2" @@ -16352,7 +16258,7 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.21.3, esbuild@npm:~0.21.4, esbuild@npm:~0.21.5": +"esbuild@npm:^0.21.3, esbuild@npm:~0.21.5": version: 0.21.5 resolution: "esbuild@npm:0.21.5" dependencies: @@ -17951,7 +17857,7 @@ __metadata: languageName: node linkType: hard -"get-func-name@npm:^2.0.1, get-func-name@npm:^2.0.2": +"get-func-name@npm:^2.0.1": version: 2.0.2 resolution: "get-func-name@npm:2.0.2" checksum: 10c0/89830fd07623fa73429a711b9daecdb304386d237c71268007f788f113505ef1d4cc2d0b9680e072c5082490aec9df5d7758bf5ac6f1c37062855e8e3dc0b9df @@ -21833,15 +21739,6 @@ __metadata: languageName: node linkType: hard -"loupe@npm:^2.3.6, loupe@npm:^2.3.7": - version: 2.3.7 - resolution: "loupe@npm:2.3.7" - dependencies: - get-func-name: "npm:^2.0.1" - checksum: 10c0/71a781c8fc21527b99ed1062043f1f2bb30bdaf54fa4cf92463427e1718bc6567af2988300bc243c1f276e4f0876f29e3cbf7b58106fdc186915687456ce5bf4 - languageName: node - linkType: hard - "loupe@npm:^3.1.0, loupe@npm:^3.1.1": version: 3.1.1 resolution: "loupe@npm:3.1.1" @@ -21958,7 +21855,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:0.30.10, magic-string@npm:^0.30.0, magic-string@npm:^0.30.10, magic-string@npm:^0.30.3, magic-string@npm:^0.30.5": +"magic-string@npm:0.30.10, magic-string@npm:^0.30.0, magic-string@npm:^0.30.10, magic-string@npm:^0.30.3": version: 0.30.10 resolution: "magic-string@npm:0.30.10" dependencies: @@ -24151,15 +24048,6 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^5.0.0": - version: 5.0.0 - resolution: "p-limit@npm:5.0.0" - dependencies: - yocto-queue: "npm:^1.0.0" - checksum: 10c0/574e93b8895a26e8485eb1df7c4b58a1a6e8d8ae41b1750cc2cc440922b3d306044fc6e9a7f74578a883d46802d9db72b30f2e612690fcef838c173261b1ed83 - languageName: node - linkType: hard - "p-locate@npm:^2.0.0": version: 2.0.0 resolution: "p-locate@npm:2.0.0" @@ -24640,20 +24528,13 @@ __metadata: languageName: node linkType: hard -"pathe@npm:^1.1.0, pathe@npm:^1.1.1, pathe@npm:^1.1.2": +"pathe@npm:^1.1.0, pathe@npm:^1.1.2": version: 1.1.2 resolution: "pathe@npm:1.1.2" checksum: 10c0/64ee0a4e587fb0f208d9777a6c56e4f9050039268faaaaecd50e959ef01bf847b7872785c36483fa5cdcdbdfdb31fef2ff222684d4fc21c330ab60395c681897 languageName: node linkType: hard -"pathval@npm:^1.1.1": - version: 1.1.1 - resolution: "pathval@npm:1.1.1" - checksum: 10c0/f63e1bc1b33593cdf094ed6ff5c49c1c0dc5dc20a646ca9725cc7fe7cd9995002d51d5685b9b2ec6814342935748b711bafa840f84c0bb04e38ff40a335c94dc - languageName: node - linkType: hard - "pathval@npm:^2.0.0": version: 2.0.0 resolution: "pathval@npm:2.0.0" @@ -27507,7 +27388,7 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.5.0, std-env@npm:^3.7.0": +"std-env@npm:^3.7.0": version: 3.7.0 resolution: "std-env@npm:3.7.0" checksum: 10c0/60edf2d130a4feb7002974af3d5a5f3343558d1ccf8d9b9934d225c638606884db4a20d2fe6440a09605bca282af6b042ae8070a10490c0800d69e82e478f41e @@ -27888,7 +27769,7 @@ __metadata: languageName: node linkType: hard -"strip-literal@npm:^2.0.0, strip-literal@npm:^2.1.0": +"strip-literal@npm:^2.1.0": version: 2.1.0 resolution: "strip-literal@npm:2.1.0" dependencies: @@ -28312,20 +28193,13 @@ __metadata: languageName: node linkType: hard -"tinybench@npm:^2.5.1, tinybench@npm:^2.8.0": +"tinybench@npm:^2.8.0": version: 2.8.0 resolution: "tinybench@npm:2.8.0" checksum: 10c0/5a9a642351fa3e4955e0cbf38f5674be5f3ba6730fd872fd23a5c953ad6c914234d5aba6ea41ef88820180a81829ceece5bd8d3967c490c5171bca1141c2f24d languageName: node linkType: hard -"tinypool@npm:^0.8.3": - version: 0.8.4 - resolution: "tinypool@npm:0.8.4" - checksum: 10c0/779c790adcb0316a45359652f4b025958c1dff5a82460fe49f553c864309b12ad732c8288be52f852973bc76317f5e7b3598878aee0beb8a33322c0e72c4a66c - languageName: node - linkType: hard - "tinypool@npm:^1.0.0": version: 1.0.0 resolution: "tinypool@npm:1.0.0" @@ -28340,13 +28214,6 @@ __metadata: languageName: node linkType: hard -"tinyspy@npm:^2.2.0": - version: 2.2.1 - resolution: "tinyspy@npm:2.2.1" - checksum: 10c0/0b4cfd07c09871e12c592dfa7b91528124dc49a4766a0b23350638c62e6a483d5a2a667de7e6282246c0d4f09996482ddaacbd01f0c05b7ed7e0f79d32409bdc - languageName: node - linkType: hard - "tinyspy@npm:^3.0.0": version: 3.0.0 resolution: "tinyspy@npm:3.0.0" @@ -28715,22 +28582,6 @@ __metadata: languageName: node linkType: hard -"tsx@npm:4.15.6": - version: 4.15.6 - resolution: "tsx@npm:4.15.6" - dependencies: - esbuild: "npm:~0.21.4" - fsevents: "npm:~2.3.3" - get-tsconfig: "npm:^4.7.5" - dependenciesMeta: - fsevents: - optional: true - bin: - tsx: dist/cli.mjs - checksum: 10c0/c44e489d35b8b4795d68164572eb9e322a707290aa0786c2aac0f5c7782a884dfec38d557d74471b981a8314b2c7f6612078451d0429db028a23cb54a37e83a0 - languageName: node - linkType: hard - "tsx@npm:4.16.2": version: 4.16.2 resolution: "tsx@npm:4.16.2" @@ -28804,13 +28655,6 @@ __metadata: languageName: node linkType: hard -"type-detect@npm:^4.0.0, type-detect@npm:^4.1.0": - version: 4.1.0 - resolution: "type-detect@npm:4.1.0" - checksum: 10c0/df8157ca3f5d311edc22885abc134e18ff8ffbc93d6a9848af5b682730ca6a5a44499259750197250479c5331a8a75b5537529df5ec410622041650a7f293e2a - languageName: node - linkType: hard - "type-fest@npm:4.23.0": version: 4.23.0 resolution: "type-fest@npm:4.23.0" @@ -28960,16 +28804,6 @@ __metadata: languageName: node linkType: hard -"typescript@npm:5.4.5": - version: 5.4.5 - resolution: "typescript@npm:5.4.5" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/2954022ada340fd3d6a9e2b8e534f65d57c92d5f3989a263754a78aba549f7e6529acc1921913560a4b816c46dce7df4a4d29f9f11a3dc0d4213bb76d043251e - languageName: node - linkType: hard - "typescript@npm:5.5.4, typescript@npm:>=3 < 6": version: 5.5.4 resolution: "typescript@npm:5.5.4" @@ -28990,16 +28824,6 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A5.4.5#optional!builtin": - version: 5.4.5 - resolution: "typescript@patch:typescript@npm%3A5.4.5#optional!builtin::version=5.4.5&hash=5adc0c" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/db2ad2a16ca829f50427eeb1da155e7a45e598eec7b086d8b4e8ba44e5a235f758e606d681c66992230d3fc3b8995865e5fd0b22a2c95486d0b3200f83072ec9 - languageName: node - linkType: hard - "typescript@patch:typescript@npm%3A5.5.4#optional!builtin, typescript@patch:typescript@npm%3A>=3 < 6#optional!builtin": version: 5.5.4 resolution: "typescript@patch:typescript@npm%3A5.5.4#optional!builtin::version=5.5.4&hash=379a07" @@ -29584,21 +29408,6 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:1.6.0": - version: 1.6.0 - resolution: "vite-node@npm:1.6.0" - dependencies: - cac: "npm:^6.7.14" - debug: "npm:^4.3.4" - pathe: "npm:^1.1.1" - picocolors: "npm:^1.0.0" - vite: "npm:^5.0.0" - bin: - vite-node: vite-node.mjs - checksum: 10c0/0807e6501ac7763e0efa2b4bd484ce99fb207e92c98624c9f8999d1f6727ac026e457994260fa7fdb7060d87546d197081e46a705d05b0136a38b6f03715cbc2 - languageName: node - linkType: hard - "vite-node@npm:2.0.4": version: 2.0.4 resolution: "vite-node@npm:2.0.4" @@ -29679,56 +29488,6 @@ __metadata: languageName: node linkType: hard -"vitest@npm:1.6.0": - version: 1.6.0 - resolution: "vitest@npm:1.6.0" - dependencies: - "@vitest/expect": "npm:1.6.0" - "@vitest/runner": "npm:1.6.0" - "@vitest/snapshot": "npm:1.6.0" - "@vitest/spy": "npm:1.6.0" - "@vitest/utils": "npm:1.6.0" - acorn-walk: "npm:^8.3.2" - chai: "npm:^4.3.10" - debug: "npm:^4.3.4" - execa: "npm:^8.0.1" - local-pkg: "npm:^0.5.0" - magic-string: "npm:^0.30.5" - pathe: "npm:^1.1.1" - picocolors: "npm:^1.0.0" - std-env: "npm:^3.5.0" - strip-literal: "npm:^2.0.0" - tinybench: "npm:^2.5.1" - tinypool: "npm:^0.8.3" - vite: "npm:^5.0.0" - vite-node: "npm:1.6.0" - why-is-node-running: "npm:^2.2.2" - peerDependencies: - "@edge-runtime/vm": "*" - "@types/node": ^18.0.0 || >=20.0.0 - "@vitest/browser": 1.6.0 - "@vitest/ui": 1.6.0 - happy-dom: "*" - jsdom: "*" - peerDependenciesMeta: - "@edge-runtime/vm": - optional: true - "@types/node": - optional: true - "@vitest/browser": - optional: true - "@vitest/ui": - optional: true - happy-dom: - optional: true - jsdom: - optional: true - bin: - vitest: vitest.mjs - checksum: 10c0/065da5b8ead51eb174d93dac0cd50042ca9539856dc25e340ea905d668c41961f7e00df3e388e6c76125b2c22091db2e8465f993d0f6944daf9598d549e562e7 - languageName: node - linkType: hard - "vitest@npm:2.0.4": version: 2.0.4 resolution: "vitest@npm:2.0.4" @@ -30138,7 +29897,7 @@ __metadata: languageName: node linkType: hard -"why-is-node-running@npm:^2.2.2, why-is-node-running@npm:^2.3.0": +"why-is-node-running@npm:^2.3.0": version: 2.3.0 resolution: "why-is-node-running@npm:2.3.0" dependencies: From 9f2034bec59b548a0cd5eed44f7d5b93d27313f3 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 26 Jul 2024 16:06:50 -0700 Subject: [PATCH 131/258] Fix lint warnings --- packages/cli/src/commands/setup/jobs/jobsHandler.js | 2 -- packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js | 2 -- packages/jobs/src/bins/__tests__/rw-jobs.test.js | 2 +- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/cli/src/commands/setup/jobs/jobsHandler.js b/packages/cli/src/commands/setup/jobs/jobsHandler.js index ca3917d51fb3..dc1569f6ed4f 100644 --- a/packages/cli/src/commands/setup/jobs/jobsHandler.js +++ b/packages/cli/src/commands/setup/jobs/jobsHandler.js @@ -2,8 +2,6 @@ import * as fs from 'node:fs' import * as path from 'node:path' import { getDMMF } from '@prisma/internals' -import chalk from 'chalk' -import execa from 'execa' import { Listr } from 'listr2' import { getPaths, transformTSToJS, writeFile } from '../../../lib' diff --git a/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js b/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js index ac43208c1486..99b21659caf8 100644 --- a/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js +++ b/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js @@ -1,5 +1,3 @@ -import path from 'node:path' - import { describe, expect, vi, it } from 'vitest' // import * as worker from '../worker' diff --git a/packages/jobs/src/bins/__tests__/rw-jobs.test.js b/packages/jobs/src/bins/__tests__/rw-jobs.test.js index 23cdefc064dc..7d9d2b9a746c 100644 --- a/packages/jobs/src/bins/__tests__/rw-jobs.test.js +++ b/packages/jobs/src/bins/__tests__/rw-jobs.test.js @@ -1,4 +1,4 @@ -import { describe, expect, vi, it } from 'vitest' +import { describe, expect, it } from 'vitest' // import * as runner from '../runner' From 92758397d7709fa03186d39cc84021ba31a5c0f3 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Sat, 27 Jul 2024 08:57:02 -0700 Subject: [PATCH 132/258] Update packages/jobs/src/adapters/PrismaAdapter.ts Co-authored-by: Tobbe Lundberg --- packages/jobs/src/adapters/PrismaAdapter.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/jobs/src/adapters/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter.ts index 0f97472b6306..958326ba68f2 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter.ts @@ -202,12 +202,7 @@ export class PrismaAdapter extends BaseAdapter { async failure(job: PrismaJob, error: Error, options?: FailureOptions) { this.logger.debug(`Job ${job.id} failure`) - // since booleans don't play nicely with || we'll explicitly check for - // `undefined` before falling back to the default - const shouldDeleteFailed = - options?.deleteFailedJobs === undefined - ? DEFAULTS.deleteFailedJobs - : options.deleteFailedJobs + const shouldDeleteFailed = options?.deleteFailedJobs ?? DEFAULTS.deleteFailedJobs if ( job.attempts >= (options?.maxAttempts || DEFAULTS.maxAttempts) && From 889228ab4ae40251d1c3587e1c6a9543fbd65bae Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Sat, 27 Jul 2024 08:59:46 -0700 Subject: [PATCH 133/258] Update packages/jobs/src/core/consts.ts Co-authored-by: Tobbe Lundberg --- packages/jobs/src/core/consts.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/jobs/src/core/consts.ts b/packages/jobs/src/core/consts.ts index 4a0db14279db..d94391ea0219 100644 --- a/packages/jobs/src/core/consts.ts +++ b/packages/jobs/src/core/consts.ts @@ -1,7 +1,8 @@ import console from 'node:console' export const DEFAULT_MAX_ATTEMPTS = 24 -export const DEFAULT_MAX_RUNTIME = 14_400 // 4 hours in seconds +/** 4 hours in seconds */ +export const DEFAULT_MAX_RUNTIME = 14_400 export const DEFAULT_SLEEP_DELAY = 5 // 5 seconds export const DEFAULT_DELETE_FAILED_JOBS = false export const DEFAULT_LOGGER = console From b41ad99a819c3a9a3d9b8b714b1eb0235cc8d8d5 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Sat, 27 Jul 2024 09:00:01 -0700 Subject: [PATCH 134/258] Update packages/jobs/src/core/consts.ts Co-authored-by: Tobbe Lundberg --- packages/jobs/src/core/consts.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/jobs/src/core/consts.ts b/packages/jobs/src/core/consts.ts index d94391ea0219..27a547340284 100644 --- a/packages/jobs/src/core/consts.ts +++ b/packages/jobs/src/core/consts.ts @@ -3,7 +3,8 @@ import console from 'node:console' export const DEFAULT_MAX_ATTEMPTS = 24 /** 4 hours in seconds */ export const DEFAULT_MAX_RUNTIME = 14_400 -export const DEFAULT_SLEEP_DELAY = 5 // 5 seconds +/** 5 seconds */ +export const DEFAULT_SLEEP_DELAY = 5 export const DEFAULT_DELETE_FAILED_JOBS = false export const DEFAULT_LOGGER = console export const DEFAULT_QUEUE = 'default' From 2d5c7c8f45ef67da1ba730b941ebf21d830ba598 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Sat, 27 Jul 2024 09:00:17 -0700 Subject: [PATCH 135/258] Update packages/jobs/src/core/consts.ts Co-authored-by: Tobbe Lundberg --- packages/jobs/src/core/consts.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/jobs/src/core/consts.ts b/packages/jobs/src/core/consts.ts index 27a547340284..fb9107d5acee 100644 --- a/packages/jobs/src/core/consts.ts +++ b/packages/jobs/src/core/consts.ts @@ -10,7 +10,10 @@ export const DEFAULT_LOGGER = console export const DEFAULT_QUEUE = 'default' export const DEFAULT_PRIORITY = 50 -// the name of the exported variable from the jobs config file that contains the adapter +/** + * The name of the exported variable from the jobs config file that contains + * the adapter + */ export const DEFAULT_ADAPTER_NAME = 'adapter' // the name of the exported variable from the jobs config file that contains the logger export const DEFAULT_LOGGER_NAME = 'logger' From f0cbdb882184104d4c41f95f60c7e274b67efa9c Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Sat, 27 Jul 2024 09:00:25 -0700 Subject: [PATCH 136/258] Update packages/jobs/src/core/consts.ts Co-authored-by: Tobbe Lundberg --- packages/jobs/src/core/consts.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/jobs/src/core/consts.ts b/packages/jobs/src/core/consts.ts index fb9107d5acee..7d8b954b3f99 100644 --- a/packages/jobs/src/core/consts.ts +++ b/packages/jobs/src/core/consts.ts @@ -15,5 +15,8 @@ export const DEFAULT_PRIORITY = 50 * the adapter */ export const DEFAULT_ADAPTER_NAME = 'adapter' -// the name of the exported variable from the jobs config file that contains the logger +/** + * The name of the exported variable from the jobs config file that contains + * the logger + */ export const DEFAULT_LOGGER_NAME = 'logger' From cbbbdaeedaeb6021c7c700f240041dfc4d64ca54 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Sat, 27 Jul 2024 09:00:37 -0700 Subject: [PATCH 137/258] Update packages/jobs/src/core/errors.ts Co-authored-by: Tobbe Lundberg --- packages/jobs/src/core/errors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jobs/src/core/errors.ts b/packages/jobs/src/core/errors.ts index a0c12008b510..8a6b7701fafe 100644 --- a/packages/jobs/src/core/errors.ts +++ b/packages/jobs/src/core/errors.ts @@ -45,7 +45,7 @@ export class JobNotFoundError extends RedwoodJobError { } } -// Throw when a job file exists, but the export does not match the filename +// Thrown when a job file exists, but the export does not match the filename export class JobExportNotFoundError extends RedwoodJobError { constructor(name: string) { super(`Job file \`${name}\` does not export a class with the same name`) From 7c77298bc7c372dc724c2833f10dcd8b8c2eea06 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sat, 27 Jul 2024 19:22:43 +0200 Subject: [PATCH 138/258] Tobbe review changes --- docs/docs/background-jobs.md | 16 +++--- packages/jobs/package.json | 5 +- packages/jobs/src/adapters/BaseAdapter.ts | 2 +- packages/jobs/src/core/RedwoodJob.ts | 25 +++++---- .../src/core/__tests__/RedwoodJob.test.ts | 6 ++- .../src/core/__typetests__/RedwoodJob.test.ts | 54 +++++++++++++++++++ packages/jobs/vitest.config.mjs | 9 ---- packages/jobs/vitest.workspace.ts | 28 ++++++++++ 8 files changed, 112 insertions(+), 33 deletions(-) create mode 100644 packages/jobs/src/core/__typetests__/RedwoodJob.test.ts delete mode 100644 packages/jobs/vitest.config.mjs create mode 100644 packages/jobs/vitest.workspace.ts diff --git a/docs/docs/background-jobs.md b/docs/docs/background-jobs.md index 5626bb7d3fbd..7bd138a90687 100644 --- a/docs/docs/background-jobs.md +++ b/docs/docs/background-jobs.md @@ -263,7 +263,7 @@ export const jobs = {} export const adapter = new PrismaAdapter({ db, logger }) ``` -This is the adapter that the job runner itself will use to run your jobs if you don't override the adapter in the job itself. In most cases this will be the same for all jobs, but just be aware that you can user different adapters for different jobs if you really want! Exporting an `adapter` in this file is required for the job runner to start. +This is the adapter that the job runner itself will use to run your jobs if you don't override the adapter in the job itself. In most cases this will be the same for all jobs, but just be aware that you can use different adapters for different jobs if you really want! Exporting an `adapter` in this file is required for the job runner to start. ### Configuring All Jobs with `RedwoodJob.config` @@ -373,10 +373,10 @@ Adapters accept an object of options when they are initialized. ```js import { db } from 'api/src/lib/db' -const adapter = new PrismaAdapter({ - db, - model: 'BackgroundJob', - logger: console, +const adapter = new PrismaAdapter({ + db, + model: 'BackgroundJob', + logger: console, }) ``` @@ -403,7 +403,7 @@ You can also set options when you create the instance. For example, if *every* i ```js // api/src/lib/jobs.js -export const jobs = { +export const jobs = { // highlight-next-line sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }) } @@ -421,7 +421,7 @@ export const createUser = async ({ input }) => { ```js // api/src/lib/jobs.js -export const jobs = { +export const jobs = { // highlight-next-line sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }) // 5 minutes } @@ -493,7 +493,7 @@ You can pass several options in a `set()` call on your instance or class: * `waitUntil`: a specific `Date` in the future to run at * `queue`: the named queue to put this job in (overrides any `static queue` set on the job itself) * `priority`: the priority to give this job (overrides any `static priority` set on the job itself) - + ## Job Runner The job runner actually executes your jobs. The runners will ask the adapter to find a job to work on. The adapter will mark the job as locked (the process name and a timestamp is set on the job) and then the worker will instantiate the job class and call `perform()` on it, passing in any args that were given to `performLater()` diff --git a/packages/jobs/package.json b/packages/jobs/package.json index 233beb7b0c9b..44b07ff5113d 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -23,6 +23,8 @@ "build:watch": "nodemon --watch src --ext \"js,jsx,ts,tsx\" --ignore dist --exec \"yarn build\"", "prepublishOnly": "NODE_ENV=production yarn build", "test": "vitest run", + "test:unit": "vitest --project regular run", + "test:types": "vitest --project typetests run", "test:watch": "vitest" }, "dependencies": { @@ -33,6 +35,5 @@ "tsx": "4.16.2", "typescript": "5.5.4", "vitest": "2.0.4" - }, - "gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1" + } } diff --git a/packages/jobs/src/adapters/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter.ts index 749911802f2a..9834ab05f3bc 100644 --- a/packages/jobs/src/adapters/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter.ts @@ -12,7 +12,7 @@ import type { BasicLogger } from '../types' // Arguments sent to an adapter to schedule a job export interface SchedulePayload { handler: string - args: any + args: unknown runAt: Date queue: string priority: number diff --git a/packages/jobs/src/core/RedwoodJob.ts b/packages/jobs/src/core/RedwoodJob.ts index 96463ec31236..fe60cc80d640 100644 --- a/packages/jobs/src/core/RedwoodJob.ts +++ b/packages/jobs/src/core/RedwoodJob.ts @@ -40,13 +40,11 @@ export abstract class RedwoodJob { // Configure all jobs to use a specific adapter and logger static config(options: JobConfigOptions) { - if (Object.keys(options).includes('adapter')) { + if ('adapter' in options) { this.adapter = options.adapter } - if ( - Object.keys(options).includes('logger') && - options.logger !== undefined - ) { + + if ('logger' in options && options.logger) { this.logger = options.logger } } @@ -72,6 +70,8 @@ export abstract class RedwoodJob { // Private property to store options set on the job. Use `set` to modify #options: JobSetOptions = {}; + // This is needed so that TS knows it's safe to do + // `this.constructor.`, like `this.constructor.adapter` declare ['constructor']: typeof RedwoodJob // A job can be instantiated manually, but this will also be invoked @@ -92,7 +92,7 @@ export abstract class RedwoodJob { } // Schedule a job to run later - performLater(...args: any[]) { + performLater(...args: unknown[]) { this.logger.info( this.#payload(args), `[RedwoodJob] Scheduling ${this.constructor.name}`, @@ -102,7 +102,7 @@ export abstract class RedwoodJob { } // Run the job immediately, within in the current process - performNow(...args: any[]) { + performNow(...args: unknown[]) { this.logger.info( this.#payload(args), `[RedwoodJob] Running ${this.constructor.name} now`, @@ -123,7 +123,7 @@ export abstract class RedwoodJob { } // Must be implemented by the subclass - abstract perform(..._args: any[]): any + abstract perform(..._args: unknown[]): any // Make private this.#options available as a getter only get options() { @@ -169,8 +169,11 @@ export abstract class RedwoodJob { } } - // Private, computes the object to be sent to the adapter for scheduling - #payload(args: any[]) { + /** + * Private method that constructs the object to be sent to the adapter for + * scheduling + */ + #payload(args: unknown) { return { handler: this.constructor.name, args, @@ -182,7 +185,7 @@ export abstract class RedwoodJob { // Private, schedules a job with the appropriate adapter, returns whatever // the adapter returns in response to a successful schedule. - #schedule(args: any[]) { + #schedule(args: unknown) { try { return this.constructor.adapter.schedule(this.#payload(args)) } catch (e: any) { diff --git a/packages/jobs/src/core/__tests__/RedwoodJob.test.ts b/packages/jobs/src/core/__tests__/RedwoodJob.test.ts index ccc2be32835e..1573b067bbd3 100644 --- a/packages/jobs/src/core/__tests__/RedwoodJob.test.ts +++ b/packages/jobs/src/core/__tests__/RedwoodJob.test.ts @@ -15,7 +15,8 @@ vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { } }) -vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) +const FAKE_NOW = new Date('2024-01-01') +vi.useFakeTimers().setSystemTime(FAKE_NOW) const mockLogger = { log: vi.fn(() => {}), @@ -242,7 +243,8 @@ describe('get runAt()', () => { it('returns a datetime `wait` seconds in the future if option set', async () => { const job = new TestJob().set({ wait: 300 }) - expect(job.runAt).toEqual(new Date(Date.UTC(2024, 0, 1, 0, 5, 0))) // 300 seconds in the future + const nowPlus300s = new Date(FAKE_NOW.getTime() + 300 * 1000) + expect(job.runAt).toEqual(nowPlus300s) }) it('returns a datetime set to `waitUntil` if option set', async () => { diff --git a/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts b/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts new file mode 100644 index 000000000000..0a48c038ed6a --- /dev/null +++ b/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, vi, it, expectTypeOf } from 'vitest' + +import { RedwoodJob } from '../RedwoodJob' + +const mockLogger = { + log: vi.fn(() => {}), + info: vi.fn(() => {}), + debug: vi.fn(() => {}), + warn: vi.fn(() => {}), + error: vi.fn(() => {}), +} + +const mockAdapter = { + options: {}, + logger: mockLogger, + schedule: vi.fn(() => {}), + find: () => null, + clear: () => {}, + success: (_job: { handler: string; args: any }) => {}, + failure: (_job: { handler: string; args: any }, _error: Error) => {}, +} + +describe('perform()', () => { + it('respects the types of its arguments', () => { + class TypeSafeJob extends RedwoodJob { + perform(id: number) { + return id + } + } + + TypeSafeJob.config({ adapter: mockAdapter }) + const job = new TypeSafeJob() + + expectTypeOf(job.perform).toEqualTypeOf<(id: number) => number>() + expectTypeOf(new TypeSafeJob().perform).toEqualTypeOf< + (id: number) => number + >() + }) + + it('can trust the types when called through performNow', () => { + class TypeSafeJob extends RedwoodJob { + perform({ id }: { id: string }) { + return id.toUpperCase() + } + } + + TypeSafeJob.config({ adapter: mockAdapter }) + const job = new TypeSafeJob() + + const returnValue = job.performNow({ id: 1 }) + + expectTypeOf(returnValue).toBeString() + }) +}) diff --git a/packages/jobs/vitest.config.mjs b/packages/jobs/vitest.config.mjs deleted file mode 100644 index 5b2b3ea44977..000000000000 --- a/packages/jobs/vitest.config.mjs +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig, configDefaults } from 'vitest/config' - -export default defineConfig({ - test: { - testTimeout: 15_000, - include: ['**/__tests__/**/*.[jt]s?(x)', '**/*.test.[jt]s?(x)'], - exclude: [...configDefaults.exclude, '**/fixtures', '**/dist'], - }, -}) diff --git a/packages/jobs/vitest.workspace.ts b/packages/jobs/vitest.workspace.ts new file mode 100644 index 000000000000..af54b4740bef --- /dev/null +++ b/packages/jobs/vitest.workspace.ts @@ -0,0 +1,28 @@ +import { configDefaults, defineWorkspace } from 'vitest/config' + +export default defineWorkspace([ + { + test: { + name: 'regular', + testTimeout: 15_000, + include: ['**/__tests__/**/*.[jt]s?(x)', '**/*.test.[jt]s?(x)'], + exclude: [ + ...configDefaults.exclude, + '**/fixtures', + '**/dist', + '**/__typetests__', + ], + }, + }, + { + test: { + name: 'typetests', + include: [], + typecheck: { + enabled: true, + include: ['**/__typetests__/**/*test.ts'], + exclude: [...configDefaults.exclude, '**/fixtures', '**/dist'], + }, + }, + }, +]) From f0db8bd892a6cc61369ebd63396f55c4ff85706d Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sat, 27 Jul 2024 20:26:53 +0200 Subject: [PATCH 139/258] Switch to tstyche --- packages/jobs/package.json | 4 +- .../src/core/__typetests__/RedwoodJob.test.ts | 49 ++++++++----------- packages/jobs/vitest.config.ts | 9 ++++ packages/jobs/vitest.workspace.ts | 28 ----------- yarn.lock | 1 + 5 files changed, 32 insertions(+), 59 deletions(-) create mode 100644 packages/jobs/vitest.config.ts delete mode 100644 packages/jobs/vitest.workspace.ts diff --git a/packages/jobs/package.json b/packages/jobs/package.json index 44b07ff5113d..595a1534e018 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -23,8 +23,7 @@ "build:watch": "nodemon --watch src --ext \"js,jsx,ts,tsx\" --ignore dist --exec \"yarn build\"", "prepublishOnly": "NODE_ENV=production yarn build", "test": "vitest run", - "test:unit": "vitest --project regular run", - "test:types": "vitest --project typetests run", + "test:types": "tstyche", "test:watch": "vitest" }, "dependencies": { @@ -32,6 +31,7 @@ }, "devDependencies": { "@redwoodjs/project-config": "workspace:*", + "tstyche": "2.1.0", "tsx": "4.16.2", "typescript": "5.5.4", "vitest": "2.0.4" diff --git a/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts b/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts index 0a48c038ed6a..59d635fffcbb 100644 --- a/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts +++ b/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts @@ -1,25 +1,7 @@ -import { describe, expect, vi, it, expectTypeOf } from 'vitest' +import { describe, expect, it } from 'tstyche' import { RedwoodJob } from '../RedwoodJob' -const mockLogger = { - log: vi.fn(() => {}), - info: vi.fn(() => {}), - debug: vi.fn(() => {}), - warn: vi.fn(() => {}), - error: vi.fn(() => {}), -} - -const mockAdapter = { - options: {}, - logger: mockLogger, - schedule: vi.fn(() => {}), - find: () => null, - clear: () => {}, - success: (_job: { handler: string; args: any }) => {}, - failure: (_job: { handler: string; args: any }, _error: Error) => {}, -} - describe('perform()', () => { it('respects the types of its arguments', () => { class TypeSafeJob extends RedwoodJob { @@ -28,27 +10,36 @@ describe('perform()', () => { } } - TypeSafeJob.config({ adapter: mockAdapter }) + expect(new TypeSafeJob().perform).type.toBe<(id: number) => number>() + }) +}) + +describe('performNow()', () => { + it('has the same arg types as perform()', () => { + class TypeSafeJob extends RedwoodJob { + perform({ id }: { id: string }) { + return id.toUpperCase() + } + } + const job = new TypeSafeJob() + const returnValue = job.performNow({ id: 'id_1' }) - expectTypeOf(job.perform).toEqualTypeOf<(id: number) => number>() - expectTypeOf(new TypeSafeJob().perform).toEqualTypeOf< - (id: number) => number - >() + expect(returnValue).type.toBeString() + expect(TypeSafeJob.performNow({ id: 'id_2' })).type.toBeString() }) - it('can trust the types when called through performNow', () => { + it('has the correct return type', () => { class TypeSafeJob extends RedwoodJob { perform({ id }: { id: string }) { return id.toUpperCase() } } - TypeSafeJob.config({ adapter: mockAdapter }) const job = new TypeSafeJob() + const returnValue = job.performNow({ id: 'id_1' }) - const returnValue = job.performNow({ id: 1 }) - - expectTypeOf(returnValue).toBeString() + expect(returnValue).type.toBeString() + expect(TypeSafeJob.performNow({ id: 'id_2' })).type.toBeString() }) }) diff --git a/packages/jobs/vitest.config.ts b/packages/jobs/vitest.config.ts new file mode 100644 index 000000000000..78a196e006c1 --- /dev/null +++ b/packages/jobs/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig, configDefaults } from 'vitest/config' + +export default defineConfig({ + test: { + testTimeout: 15_000, + include: ['**/__tests__/**/*.test.[jt]s'], + exclude: [...configDefaults.exclude, '**/fixtures', '**/dist'], + }, +}) diff --git a/packages/jobs/vitest.workspace.ts b/packages/jobs/vitest.workspace.ts deleted file mode 100644 index af54b4740bef..000000000000 --- a/packages/jobs/vitest.workspace.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { configDefaults, defineWorkspace } from 'vitest/config' - -export default defineWorkspace([ - { - test: { - name: 'regular', - testTimeout: 15_000, - include: ['**/__tests__/**/*.[jt]s?(x)', '**/*.test.[jt]s?(x)'], - exclude: [ - ...configDefaults.exclude, - '**/fixtures', - '**/dist', - '**/__typetests__', - ], - }, - }, - { - test: { - name: 'typetests', - include: [], - typecheck: { - enabled: true, - include: ['**/__typetests__/**/*test.ts'], - exclude: [...configDefaults.exclude, '**/fixtures', '**/dist'], - }, - }, - }, -]) diff --git a/yarn.lock b/yarn.lock index 841707a62532..994bff227352 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8319,6 +8319,7 @@ __metadata: dependencies: "@redwoodjs/project-config": "workspace:*" fast-glob: "npm:3.3.2" + tstyche: "npm:2.1.0" tsx: "npm:4.16.2" typescript: "npm:5.5.4" vitest: "npm:2.0.4" From b8695780a69c102fb56bc1282664a4df6848780a Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sun, 28 Jul 2024 13:01:08 +0200 Subject: [PATCH 140/258] Better types --- packages/jobs/src/core/RedwoodJob.ts | 96 +++++++++++-------- .../src/core/__typetests__/RedwoodJob.test.ts | 11 ++- 2 files changed, 64 insertions(+), 43 deletions(-) diff --git a/packages/jobs/src/core/RedwoodJob.ts b/packages/jobs/src/core/RedwoodJob.ts index fe60cc80d640..83e363395550 100644 --- a/packages/jobs/src/core/RedwoodJob.ts +++ b/packages/jobs/src/core/RedwoodJob.ts @@ -23,22 +23,26 @@ export interface JobSetOptions { priority?: number } -export abstract class RedwoodJob { - // The adapter to use for scheduling all jobs of this class +export abstract class RedwoodJob> { + /** The adapter to use for scheduling all jobs of this class */ static adapter: BaseAdapter - // The logger to use when scheduling all jobs of the class. Defaults to - // `console` if not explicitly set. + /** + * The logger to use when scheduling all jobs of the class. Defaults to + * `console` if not explicitly set. + */ static logger: BasicLogger = DEFAULT_LOGGER - // The queue that all jobs of this class will be enqueued to + /** The queue that all jobs of this class will be enqueued to */ static queue: string = DEFAULT_QUEUE - // The priority that all jobs of this class will be given - // Lower numbers are higher priority (1 is executed before 100) + /** + * The priority that all jobs of this class will be given + * Lower numbers are higher priority (1 is executed before 100) + */ static priority: number = DEFAULT_PRIORITY - // Configure all jobs to use a specific adapter and logger + /** Configure all jobs to use a specific adapter and logger */ static config(options: JobConfigOptions) { if ('adapter' in options) { this.adapter = options.adapter @@ -49,33 +53,43 @@ export abstract class RedwoodJob { } } - // Class method to schedule a job to run later - static performLater(this: new () => T, ...args: any[]) { + /** Class method to schedule a job to run later */ + static performLater< + TClass extends RedwoodJob, + TPerformArgs extends Array, + >(this: new () => TClass, ...args: TPerformArgs) { return new this().performLater(...args) } - // Class method to run the job immediately in the current process - static performNow(this: new () => T, ...args: any[]) { + /** Class method to run the job immediately in the current process */ + static performNow< + TClass extends RedwoodJob, + TPerformArgs extends Array, + >(this: new () => TClass, ...args: TPerformArgs) { return new this().performNow(...args) } - // Set options on the job before enqueueing it - static set( - this: new (options: JobSetOptions) => T, - options: JobSetOptions = {}, - ) { + /** Set options on the job before enqueueing it */ + static set< + TClass extends RedwoodJob, + TPerformArgs extends Array, + >(this: new (options: JobSetOptions) => TClass, options: JobSetOptions = {}) { return new this(options) } - // Private property to store options set on the job. Use `set` to modify + /** Private property to store options set on the job. Use `set` to modify */ #options: JobSetOptions = {}; - // This is needed so that TS knows it's safe to do - // `this.constructor.`, like `this.constructor.adapter` + /** + * This is needed so that TS knows it's safe to do + * `this.constructor.`, like `this.constructor.adapter` + */ declare ['constructor']: typeof RedwoodJob - // A job can be instantiated manually, but this will also be invoked - // automatically by .set() or .performLater() + /** + * A job can be instantiated manually, but this will also be invoked + * automatically by .set() or .performLater() + */ constructor(options: JobSetOptions = {}) { this.set(options) @@ -84,15 +98,17 @@ export abstract class RedwoodJob { } } - // Set options on the job before enqueueing it, merges with any existing - // options set upon instantiation + /** + * Set options on the job before enqueueing it, merges with any existing + * options set upon instantiation + */ set(options: JobSetOptions = {}) { this.#options = { ...this.#options, ...options } return this } - // Schedule a job to run later - performLater(...args: unknown[]) { + /** Schedule a job to run later */ + performLater(...args: TPerformArgs) { this.logger.info( this.#payload(args), `[RedwoodJob] Scheduling ${this.constructor.name}`, @@ -101,8 +117,8 @@ export abstract class RedwoodJob { return this.#schedule(args) } - // Run the job immediately, within in the current process - performNow(...args: unknown[]) { + /** Run the job immediately, within in the current process */ + performNow(...args: TPerformArgs): ReturnType { this.logger.info( this.#payload(args), `[RedwoodJob] Running ${this.constructor.name} now`, @@ -122,10 +138,10 @@ export abstract class RedwoodJob { } } - // Must be implemented by the subclass - abstract perform(..._args: unknown[]): any + /** Must be implemented by the subclass */ + abstract perform(...args: TPerformArgs): any - // Make private this.#options available as a getter only + /** Make private this.#options available as a getter only */ get options() { return this.#options } @@ -154,11 +170,13 @@ export abstract class RedwoodJob { return this.#options.waitUntil } - // Determines when the job should run. - // - // - If no options were set, defaults to running as soon as possible - // - If a `wait` option is present it sets the number of seconds to wait - // - If a `waitUntil` option is present it runs at that specific datetime + /** + * Determines when the job should run. + * + * - If no options were set, defaults to running as soon as possible + * - If a `wait` option is present it sets the number of seconds to wait + * - If a `waitUntil` option is present it runs at that specific datetime + */ get runAt() { if (this.#options.wait) { return new Date(new Date().getTime() + this.#options.wait * 1000) @@ -183,8 +201,10 @@ export abstract class RedwoodJob { } } - // Private, schedules a job with the appropriate adapter, returns whatever - // the adapter returns in response to a successful schedule. + /** + * Private, schedules a job with the appropriate adapter, returns whatever + * the adapter returns in response to a successful schedule. + */ #schedule(args: unknown) { try { return this.constructor.adapter.schedule(this.#payload(args)) diff --git a/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts b/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts index 59d635fffcbb..3d221629fdec 100644 --- a/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts +++ b/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts @@ -4,7 +4,7 @@ import { RedwoodJob } from '../RedwoodJob' describe('perform()', () => { it('respects the types of its arguments', () => { - class TypeSafeJob extends RedwoodJob { + class TypeSafeJob extends RedwoodJob<[number]> { perform(id: number) { return id } @@ -15,9 +15,10 @@ describe('perform()', () => { }) describe('performNow()', () => { + type TPerformArgs = [{ id: string }] it('has the same arg types as perform()', () => { - class TypeSafeJob extends RedwoodJob { - perform({ id }: { id: string }) { + class TypeSafeJob extends RedwoodJob { + perform({ id }: TPerformArgs[0]) { return id.toUpperCase() } } @@ -30,8 +31,8 @@ describe('performNow()', () => { }) it('has the correct return type', () => { - class TypeSafeJob extends RedwoodJob { - perform({ id }: { id: string }) { + class TypeSafeJob extends RedwoodJob { + perform({ id }: TPerformArgs[0]) { return id.toUpperCase() } } From 13704f5e696506af88913054cc7e8dae5339eeef Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sun, 28 Jul 2024 20:37:54 +0200 Subject: [PATCH 141/258] more tests, some that fail --- .../src/core/__typetests__/RedwoodJob.test.ts | 114 +++++++++++++++++- 1 file changed, 111 insertions(+), 3 deletions(-) diff --git a/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts b/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts index 3d221629fdec..8a2d902ec4d0 100644 --- a/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts +++ b/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts @@ -16,7 +16,22 @@ describe('perform()', () => { describe('performNow()', () => { type TPerformArgs = [{ id: string }] - it('has the same arg types as perform()', () => { + + it('has the same function signature as perform()', () => { + class TypeSafeJob extends RedwoodJob { + perform({ id }: TPerformArgs[0]) { + return id.toUpperCase() + } + } + + const job = new TypeSafeJob() + + expect(job.performNow).type.toBe<(args: TPerformArgs[0]) => string>() + expect(job.performNow).type.toBe() + expect(job.performNow).type.toBe(job.perform) + }) + + it('has the correct return type', () => { class TypeSafeJob extends RedwoodJob { perform({ id }: TPerformArgs[0]) { return id.toUpperCase() @@ -26,10 +41,47 @@ describe('performNow()', () => { const job = new TypeSafeJob() const returnValue = job.performNow({ id: 'id_1' }) + // Regular method expect(returnValue).type.toBeString() + + // Static method expect(TypeSafeJob.performNow({ id: 'id_2' })).type.toBeString() }) + it('can take more than one parameter in a typesafe way', () => { + type TPerformArgs = [number, string] + + class TypeSafeJob extends RedwoodJob { + perform(num: number, str: string) { + return { num, str } + } + } + + const job = new TypeSafeJob() + const returnValue = job.performNow(1, 'str') + + expect(returnValue).type.toBe<{ num: number; str: string }>() + }) +}) + +describe('performLater()', () => { + type TPerformArgs = [{ id: string }] + + it('has the same arg types as perform()', () => { + class TypeSafeJob extends RedwoodJob { + perform({ id }: TPerformArgs[0]) { + return id.toUpperCase() + } + } + + const job = new TypeSafeJob() + + expect>().type.toBe() + expect>().type.toBe< + Parameters<(typeof job)['perform']> + >() + }) + it('has the correct return type', () => { class TypeSafeJob extends RedwoodJob { perform({ id }: TPerformArgs[0]) { @@ -38,9 +90,65 @@ describe('performNow()', () => { } const job = new TypeSafeJob() - const returnValue = job.performNow({ id: 'id_1' }) + const returnValue = job.performLater({ id: 'id_1' }) + // Regular method + // TODO: Fix this test. It should probably not be `.toBeString()` expect(returnValue).type.toBeString() - expect(TypeSafeJob.performNow({ id: 'id_2' })).type.toBeString() + + // Static method + // TODO: Fix this test. It should probably not be `.toBeString()` + expect(TypeSafeJob.performLater({ id: 'id_2' })).type.toBeString() + }) + + it('can take more than one parameter in a typesafe way', () => { + type TPerformArgs = [number, string] + + class TypeSafeJob extends RedwoodJob { + perform(num: number, str: string) { + return { num, str } + } + } + + const job = new TypeSafeJob() + const returnValue = job.performLater(1, 'str') + + expect(returnValue).type.toBe<{ num: number; str: string }>() + }) + + it('errors with the wrong number of args', () => { + type TPerformArgs = [number, string] + + class TypeSafeJob extends RedwoodJob { + perform(num: number, str: string) { + return { num, str } + } + } + + const job = new TypeSafeJob() + + expect(job.performLater(4)).type.toRaiseError( + 'Expected 2 arguments, but got 1', + ) + + expect(job.performLater(4, 'bar', 'baz')).type.toRaiseError( + 'Expected 2 arguments, but got 3', + ) + }) + + it('errors with the wrong type of args', () => { + type TPerformArgs = [number, string] + + class TypeSafeJob extends RedwoodJob { + perform(num: number, str: string) { + return { num, str } + } + } + + const job = new TypeSafeJob() + + expect(job.performLater(4, 5)).type.toRaiseError( + "Argument of type 'number' is not assignable to parameter of type 'string'.", + ) }) }) From a5d4027ca83c969704e5ae5762fdd4877b048106 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 1 Aug 2024 17:44:13 -0700 Subject: [PATCH 142/258] Rename "hander" to "job" --- packages/jobs/src/adapters/BaseAdapter.ts | 2 +- packages/jobs/src/adapters/PrismaAdapter.ts | 7 ++++--- packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/jobs/src/adapters/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter.ts index 9834ab05f3bc..b23ce5033c11 100644 --- a/packages/jobs/src/adapters/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter.ts @@ -11,7 +11,7 @@ import type { BasicLogger } from '../types' // Arguments sent to an adapter to schedule a job export interface SchedulePayload { - handler: string + job: string args: unknown runAt: Date queue: string diff --git a/packages/jobs/src/adapters/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter.ts index 958326ba68f2..03f4866ba25e 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter.ts @@ -202,7 +202,8 @@ export class PrismaAdapter extends BaseAdapter { async failure(job: PrismaJob, error: Error, options?: FailureOptions) { this.logger.debug(`Job ${job.id} failure`) - const shouldDeleteFailed = options?.deleteFailedJobs ?? DEFAULTS.deleteFailedJobs + const shouldDeleteFailed = + options?.deleteFailedJobs ?? DEFAULTS.deleteFailedJobs if ( job.attempts >= (options?.maxAttempts || DEFAULTS.maxAttempts) && @@ -234,10 +235,10 @@ export class PrismaAdapter extends BaseAdapter { // Schedules a job by creating a new record in a `BackgroundJob` table // (or whatever the accessor is configured to point to). - async schedule({ handler, args, runAt, queue, priority }: SchedulePayload) { + async schedule({ job, args, runAt, queue, priority }: SchedulePayload) { return await this.accessor.create({ data: { - handler: JSON.stringify({ handler, args }), + handler: JSON.stringify({ job, args }), runAt, queue, priority, diff --git a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts index de4c64600520..ff21f18e2d01 100644 --- a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts +++ b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts @@ -102,7 +102,7 @@ describe('schedule()', () => { .mockReturnValue({ id: 1 }) const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) await adapter.schedule({ - handler: 'RedwoodJob', + job: 'RedwoodJob', args: ['foo', 'bar'], queue: 'default', priority: 50, @@ -112,7 +112,7 @@ describe('schedule()', () => { expect(createSpy).toHaveBeenCalledWith({ data: { handler: JSON.stringify({ - handler: 'RedwoodJob', + job: 'RedwoodJob', args: ['foo', 'bar'], }), priority: 50, From ae62078db7bd4a51d0b88145239a80a87032a5c8 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 1 Aug 2024 17:44:21 -0700 Subject: [PATCH 143/258] Adds errors --- packages/jobs/src/core/errors.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/jobs/src/core/errors.ts b/packages/jobs/src/core/errors.ts index 8a6b7701fafe..baad0f9f3656 100644 --- a/packages/jobs/src/core/errors.ts +++ b/packages/jobs/src/core/errors.ts @@ -10,10 +10,10 @@ export class RedwoodJobError extends Error { } } -// Thrown when trying to schedule a job without an adapter configured +// Thrown when trying to configure a scheduler without an adapter export class AdapterNotConfiguredError extends RedwoodJobError { constructor() { - super('No adapter configured for RedwoodJob') + super('No adapter configured for the job scheduler') } } @@ -136,3 +136,9 @@ export class PerformError extends RethrownJobError { super(message, error) } } + +export class QueueNotDefinedError extends RedwoodJobError { + constructor() { + super('Scheduler requires a named `queue` to place jobs in') + } +} From b07615dcb7540b06d8d3336d452be1e8a4f74515 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 1 Aug 2024 17:44:36 -0700 Subject: [PATCH 144/258] Always load from api/dist --- packages/jobs/src/core/loaders.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/packages/jobs/src/core/loaders.ts b/packages/jobs/src/core/loaders.ts index a43b1488c964..82b22a5c0906 100644 --- a/packages/jobs/src/core/loaders.ts +++ b/packages/jobs/src/core/loaders.ts @@ -8,14 +8,6 @@ import { getPaths } from '@redwoodjs/project-config' import { JobsLibNotFoundError, JobNotFoundError } from './errors' -const DEV_ENVIRONMENTS = ['development', 'test'] - -const isDev = DEV_ENVIRONMENTS.includes(process.env.NODE_ENV || '') - -if (isDev) { - registerApiSideBabelHook() -} - export function makeFilePath(path: string) { return pathToFileURL(path).href } @@ -23,9 +15,7 @@ export function makeFilePath(path: string) { // Loads the named export from the app's jobs config in api/src/lib/jobs.{js,ts} // to configure the worker, defaults to `workerConfig` export const loadJobsConfig = async () => { - const jobsConfigPath = isDev - ? getPaths().api.jobsConfig - : getPaths().api.distJobsConfig + const jobsConfigPath = getPaths().api.jobsConfig if (jobsConfigPath) { return require(jobsConfigPath) @@ -36,14 +26,14 @@ export const loadJobsConfig = async () => { // Loads a job from the app's filesystem in api/src/jobs export const loadJob = async (name: string) => { - const baseJobsPath = isDev ? getPaths().api.jobs : getPaths().api.distJobs + const jobsPath = getPaths().api.distJobs // Specifying {js,ts} extensions, so we don't accidentally try to load .json // files or similar - const files = fg.sync(`**/${name}.{js,ts}`, { cwd: baseJobsPath }) + const files = fg.sync(`**/${name}.{js,ts}`, { cwd: jobsPath }) if (!files[0]) { throw new JobNotFoundError(name) } - const jobModule = require(path.join(baseJobsPath, files[0])) + const jobModule = require(path.join(jobsPath, files[0])) return jobModule } From 0bb3d5a04ce3cede461cdab8bd101427d4a888fc Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 1 Aug 2024 17:45:13 -0700 Subject: [PATCH 145/258] Start of rewrite for new design --- packages/jobs/src/core/RedwoodJob.ts | 261 +++------------- packages/jobs/src/core/Scheduler.js | 75 +++++ .../jobs/src/core/__tests__/Scheduler.test.ts | 287 ++++++++++++++++++ .../core/__tests__/createScheduler.test.ts | 60 ++++ packages/jobs/src/core/__tests__/mocks.ts | 19 ++ 5 files changed, 491 insertions(+), 211 deletions(-) create mode 100644 packages/jobs/src/core/Scheduler.js create mode 100644 packages/jobs/src/core/__tests__/Scheduler.test.ts create mode 100644 packages/jobs/src/core/__tests__/createScheduler.test.ts create mode 100644 packages/jobs/src/core/__tests__/mocks.ts diff --git a/packages/jobs/src/core/RedwoodJob.ts b/packages/jobs/src/core/RedwoodJob.ts index 83e363395550..ec9e4b7a9b1a 100644 --- a/packages/jobs/src/core/RedwoodJob.ts +++ b/packages/jobs/src/core/RedwoodJob.ts @@ -1,218 +1,57 @@ -// Base class for all jobs, providing a common interface for scheduling jobs. -// At a minimum you must implement the `perform` method in your job subclass. - -import type { BaseAdapter } from '../adapters/BaseAdapter' -import type { BasicLogger } from '../types' - -import { DEFAULT_LOGGER, DEFAULT_QUEUE, DEFAULT_PRIORITY } from './consts' -import { - AdapterNotConfiguredError, - SchedulingError, - PerformError, -} from './errors' - -export interface JobConfigOptions { - adapter: BaseAdapter - logger?: BasicLogger -} - -export interface JobSetOptions { - wait?: number - waitUntil?: Date - queue?: string - priority?: number -} - -export abstract class RedwoodJob> { - /** The adapter to use for scheduling all jobs of this class */ - static adapter: BaseAdapter - - /** - * The logger to use when scheduling all jobs of the class. Defaults to - * `console` if not explicitly set. - */ - static logger: BasicLogger = DEFAULT_LOGGER - - /** The queue that all jobs of this class will be enqueued to */ - static queue: string = DEFAULT_QUEUE - - /** - * The priority that all jobs of this class will be given - * Lower numbers are higher priority (1 is executed before 100) - */ - static priority: number = DEFAULT_PRIORITY - - /** Configure all jobs to use a specific adapter and logger */ - static config(options: JobConfigOptions) { - if ('adapter' in options) { - this.adapter = options.adapter - } - - if ('logger' in options && options.logger) { - this.logger = options.logger - } - } - - /** Class method to schedule a job to run later */ - static performLater< - TClass extends RedwoodJob, - TPerformArgs extends Array, - >(this: new () => TClass, ...args: TPerformArgs) { - return new this().performLater(...args) - } - - /** Class method to run the job immediately in the current process */ - static performNow< - TClass extends RedwoodJob, - TPerformArgs extends Array, - >(this: new () => TClass, ...args: TPerformArgs) { - return new this().performNow(...args) - } - - /** Set options on the job before enqueueing it */ - static set< - TClass extends RedwoodJob, - TPerformArgs extends Array, - >(this: new (options: JobSetOptions) => TClass, options: JobSetOptions = {}) { - return new this(options) - } - - /** Private property to store options set on the job. Use `set` to modify */ - #options: JobSetOptions = {}; - - /** - * This is needed so that TS knows it's safe to do - * `this.constructor.`, like `this.constructor.adapter` - */ - declare ['constructor']: typeof RedwoodJob - - /** - * A job can be instantiated manually, but this will also be invoked - * automatically by .set() or .performLater() - */ - constructor(options: JobSetOptions = {}) { - this.set(options) - - if (!this.constructor.adapter) { - throw new AdapterNotConfiguredError() - } - } - - /** - * Set options on the job before enqueueing it, merges with any existing - * options set upon instantiation - */ - set(options: JobSetOptions = {}) { - this.#options = { ...this.#options, ...options } - return this - } - - /** Schedule a job to run later */ - performLater(...args: TPerformArgs) { - this.logger.info( - this.#payload(args), - `[RedwoodJob] Scheduling ${this.constructor.name}`, - ) - - return this.#schedule(args) - } - - /** Run the job immediately, within in the current process */ - performNow(...args: TPerformArgs): ReturnType { - this.logger.info( - this.#payload(args), - `[RedwoodJob] Running ${this.constructor.name} now`, - ) - - try { - return this.perform(...args) - } catch (e: any) { - if (e instanceof TypeError) { - throw e - } else { - throw new PerformError( - `[${this.constructor.name}] exception when running job`, - e, - ) - } +import { Scheduler } from './Scheduler' + +class RedwoodJob { + // config looks like: + // adapters: { [key: string]: BaseAdapter } + // queues: string[] + // logger: BasicLogger + // workers: WorkerConfig[] + // adapter: keyof this.config.adapters + // queue: oneof this.config.queues + // maxAttempts: number + // maxRuntime: number + // deleteFailedJobs: boolean + // sleepDelay: number + // count: number + constructor(config) { + this.config = config + + this.adapters = config.adapters + this.queues = config.queues + this.logger = config.logger + this.workers = config.workers + } + + // schedulerConfig: + // adapter: keyof this.config.adapters + // queue: oneof this.config.queues + // priority: number + // wait: number (either/or) + // waitUntil: Date + createScheduler(schedulerConfig) { + const scheduler = new Scheduler({ + config: schedulerConfig, + adapter: this.adapters[schedulerConfig.adapter], + logger: this.logger, + }) + + return (job, jobArgs = [], jobOptions = {}) => { + return scheduler.schedule(job, jobArgs, jobOptions) } } - /** Must be implemented by the subclass */ - abstract perform(...args: TPerformArgs): any - - /** Make private this.#options available as a getter only */ - get options() { - return this.#options - } - - get adapter() { - return this.constructor.adapter - } - - get logger() { - return this.constructor.logger - } - - get queue() { - return this.#options.queue || this.constructor.queue + // jobDefinition looks like: + // queue: one of this.config.queues + // priority: number + // wait: number (either/or) + // waitUntil: Date + // perform: function + // userDefinedFunction(s) + createJob(jobDefinition) { + return jobDefinition } - get priority() { - return this.#options.priority || this.constructor.priority - } - - get wait() { - return this.#options.wait - } - - get waitUntil() { - return this.#options.waitUntil - } - - /** - * Determines when the job should run. - * - * - If no options were set, defaults to running as soon as possible - * - If a `wait` option is present it sets the number of seconds to wait - * - If a `waitUntil` option is present it runs at that specific datetime - */ - get runAt() { - if (this.#options.wait) { - return new Date(new Date().getTime() + this.#options.wait * 1000) - } else if (this.#options.waitUntil) { - return this.#options.waitUntil - } else { - return new Date() - } - } - - /** - * Private method that constructs the object to be sent to the adapter for - * scheduling - */ - #payload(args: unknown) { - return { - handler: this.constructor.name, - args, - runAt: this.runAt, - queue: this.queue, - priority: this.priority, - } - } - - /** - * Private, schedules a job with the appropriate adapter, returns whatever - * the adapter returns in response to a successful schedule. - */ - #schedule(args: unknown) { - try { - return this.constructor.adapter.schedule(this.#payload(args)) - } catch (e: any) { - throw new SchedulingError( - `[RedwoodJob] Exception when scheduling ${this.constructor.name}`, - e, - ) - } + createWorker() { + // coming soon } } diff --git a/packages/jobs/src/core/Scheduler.js b/packages/jobs/src/core/Scheduler.js new file mode 100644 index 000000000000..767447ffa7c1 --- /dev/null +++ b/packages/jobs/src/core/Scheduler.js @@ -0,0 +1,75 @@ +import { + DEFAULT_LOGGER, + DEFAULT_PRIORITY, + DEFAULT_WAIT, + DEFAULT_WAIT_UNTIL, +} from './consts' +import { + AdapterNotConfiguredError, + QueueNotConfiguredError, + SchedulingError, +} from './errors' + +export class Scheduler { + constructor({ config, adapter, logger }) { + this.config = config + this.logger = logger ?? DEFAULT_LOGGER + this.adapter = adapter + + if (!this.adapter) { + throw new AdapterNotConfiguredError() + } + } + + computeRunAt(wait, waitUntil) { + if (wait && wait > 0) { + return new Date(new Date().getTime() + wait * 1000) + } else if (waitUntil) { + return waitUntil + } else { + return new Date() + } + } + + buildPayload(job, args, options) { + const queue = options.queue ?? job.queue ?? this.config.queue + const priority = + options.priority ?? + job.priority ?? + this.config.priority ?? + DEFAULT_PRIORITY + const wait = options.wait ?? job.wait ?? DEFAULT_WAIT + const waitUntil = options.waitUntil ?? job.waitUntil ?? DEFAULT_WAIT_UNTIL + + if (!queue) { + throw new QueueNotConfiguredError() + } + + return { + job: job.name, + args: args, + runAt: this.computeRunAt(wait, waitUntil), + queue: queue, + priority: priority, + } + } + + async schedule({ job, jobArgs = [], jobOptions = {} } = {}) { + const payload = this.buildPayload(job, jobArgs, jobOptions) + + this.logger.info( + payload, + `[RedwoodJob] Scheduling ${this.constructor.name}`, + ) + + try { + this.adapter.schedule(payload) + return true + } catch (e) { + throw new SchedulingError( + `[RedwoodJob] Exception when scheduling ${payload.name}`, + e, + ) + } + } +} diff --git a/packages/jobs/src/core/__tests__/Scheduler.test.ts b/packages/jobs/src/core/__tests__/Scheduler.test.ts new file mode 100644 index 000000000000..761215a2fc0e --- /dev/null +++ b/packages/jobs/src/core/__tests__/Scheduler.test.ts @@ -0,0 +1,287 @@ +import { beforeEach, describe, expect, vi, it } from 'vitest' + +import type CliHelpers from '@redwoodjs/cli-helpers' + +import { + DEFAULT_LOGGER, + DEFAULT_PRIORITY, + DEFAULT_QUEUE, + DEFAULT_WAIT, + DEFAULT_WAIT_UNTIL, +} from '../consts.ts' +import { AdapterNotConfiguredError, SchedulingError } from '../errors.ts' +import { Scheduler } from '../Scheduler.js' + +import { mockAdapter, mockLogger } from './mocks.ts' + +vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { + const originalCliHelpers = await importOriginal() + + return { + ...originalCliHelpers, + isTypeScriptProject: () => true, + } +}) + +const FAKE_NOW = new Date('2024-01-01') +vi.useFakeTimers().setSystemTime(FAKE_NOW) + +describe('constructor', () => { + it('throws an error if adapter is not configured', () => { + expect(() => { + new Scheduler() + }).toThrow(AdapterNotConfiguredError) + }) + + it('creates this.config', () => { + const scheduler = new Scheduler({ config: { adapter: mockAdapter } }) + + expect(scheduler.config).toEqual({ adapter: mockAdapter }) + }) + + it('creates this.job', () => { + const job = () => {} + const scheduler = new Scheduler({ config: { adapter: mockAdapter }, job }) + + expect(scheduler.job).toEqual(job) + }) + + it('creates this.jobArgs', () => { + const jobArgs = ['foo', 123] + const scheduler = new Scheduler({ + config: { adapter: mockAdapter }, + jobArgs, + }) + + expect(scheduler.jobArgs).toEqual(jobArgs) + }) + + it('creates this.jobOptions', () => { + const jobOptions = { wait: 300 } + const scheduler = new Scheduler({ + config: { adapter: mockAdapter }, + jobOptions, + }) + + expect(scheduler.jobOptions).toEqual(jobOptions) + }) + + it('creates this.adapter', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter }, + }) + + expect(scheduler.adapter).toEqual(mockAdapter) + }) + + it('creates this.logger', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter, logger: mockLogger }, + }) + + expect(scheduler.logger).toEqual(mockLogger) + }) + + it('sets a default logger if none configured', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter }, + }) + + expect(scheduler.logger).toEqual(DEFAULT_LOGGER) + }) +}) + +describe('get queue()', () => { + it('returns jobOptions.queue', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter }, + jobOptions: { queue: 'foo' }, + }) + + expect(scheduler.queue).toEqual('foo') + }) + + it('falls back to config.queue', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter, queue: 'bar' }, + }) + + expect(scheduler.queue).toEqual('bar') + }) + + it('falls back to default queue', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter }, + }) + + expect(scheduler.queue).toEqual(DEFAULT_QUEUE) + }) +}) + +describe('get priority()', () => { + it('returns jobOptions.priority', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter }, + jobOptions: { priority: 1 }, + }) + + expect(scheduler.priority).toEqual(1) + }) + + it('falls back to config.priority', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter, priority: 2 }, + }) + + expect(scheduler.priority).toEqual(2) + }) + + it('falls back to default priority', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter }, + }) + + expect(scheduler.priority).toEqual(DEFAULT_PRIORITY) + }) +}) + +describe('get wait()', () => { + it('returns jobOptions.wait', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter }, + jobOptions: { wait: 300 }, + }) + + expect(scheduler.wait).toEqual(300) + }) + + it('falls back to config.wait', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter, wait: 200 }, + }) + + expect(scheduler.wait).toEqual(200) + }) + + it('falls back to default wait', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter }, + }) + + expect(scheduler.wait).toEqual(DEFAULT_WAIT) + }) +}) + +describe('get waitUntil()', () => { + it('returns jobOptions.waitUntil', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter }, + jobOptions: { waitUntil: new Date() }, + }) + + expect(scheduler.waitUntil).toEqual(expect.any(Date)) + }) + + it('falls back to config.waitUntil', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter, waitUntil: new Date() }, + }) + + expect(scheduler.waitUntil).toEqual(expect.any(Date)) + }) + + it('falls back to default waitUntil', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter }, + }) + + expect(scheduler.waitUntil).toEqual(DEFAULT_WAIT_UNTIL) + }) +}) + +describe('get runAt()', () => { + it('returns wait seconds in the future if set', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter }, + jobOptions: { wait: 300 }, + }) + + expect(scheduler.runAt).toEqual(new Date(FAKE_NOW.getTime() + 300 * 1000)) + }) + + it('returns waitUntil date if set', () => { + const waitUntil = new Date(2025, 0, 1, 12, 0, 0) + const scheduler = new Scheduler({ + config: { adapter: mockAdapter }, + jobOptions: { waitUntil }, + }) + + expect(scheduler.runAt).toEqual(waitUntil) + }) + + it('returns current date if no wait or waitUntil', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter }, + }) + + expect(scheduler.runAt).toEqual(FAKE_NOW) + }) +}) + +describe('payload()', () => { + it('returns an object with job, args, runAt, queue, and priority', () => { + const scheduler = new Scheduler({ + config: { adapter: mockAdapter }, + job: function CustomJob() {}, + jobArgs: ['foo', 123], + jobOptions: { queue: 'custom', priority: 15 }, + }) + + expect(scheduler.payload()).toEqual({ + job: 'CustomJob', + args: ['foo', 123], + runAt: FAKE_NOW, + queue: 'custom', + priority: 15, + }) + }) +}) + +describe('schedule()', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const scheduler = new Scheduler({ + config: { adapter: mockAdapter, logger: mockLogger }, + job: function CustomJob() {}, + jobArgs: ['foo', 123], + jobOptions: { queue: 'custom', priority: 15 }, + }) + + it('calls adapter.schedule with payload', () => { + scheduler.schedule() + + expect(mockAdapter.schedule).toHaveBeenCalledWith({ + job: 'CustomJob', + args: ['foo', 123], + runAt: FAKE_NOW, + queue: 'custom', + priority: 15, + }) + }) + + it('returns true', () => { + expect(scheduler.schedule()).toEqual(true) + }) + + it('catches and rethrows any errors when scheduling', () => { + mockAdapter.schedule.mockImplementation(() => { + throw new Error('Failed to schedule') + }) + + expect(() => { + scheduler.schedule() + }).toThrow(SchedulingError) + }) +}) diff --git a/packages/jobs/src/core/__tests__/createScheduler.test.ts b/packages/jobs/src/core/__tests__/createScheduler.test.ts new file mode 100644 index 000000000000..c1dcaedb6512 --- /dev/null +++ b/packages/jobs/src/core/__tests__/createScheduler.test.ts @@ -0,0 +1,60 @@ +import { idText } from 'typescript' +import { describe, expect, vi, it } from 'vitest' + +import type CliHelpers from '@redwoodjs/cli-helpers' + +import { createScheduler } from '../createScheduler' +import { Scheduler } from '../Scheduler' + +import { mockAdapter } from './mocks' + +vi.mock('../Scheduler') + +vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { + const originalCliHelpers = await importOriginal() + + return { + ...originalCliHelpers, + isTypeScriptProject: () => true, + } +}) + +describe('createScheduler', () => { + it('returns a function', () => { + const scheduler = createScheduler({ + adapter: mockAdapter, + }) + + expect(scheduler).toBeInstanceOf(Function) + }) + + it('creates an instance of the JobScheduler when the resulting function is called', () => { + const config = { adapter: mockAdapter } + const job = () => {} + const jobArgs = ['foo'] + const jobOptions = { wait: 300 } + const scheduler = createScheduler(config) + + scheduler(job, jobArgs, jobOptions) + + expect(Scheduler).toHaveBeenCalledWith({ + config, + job, + jobArgs, + jobOptions, + }) + }) + + it('calls the `schedule` method on the JobScheduler instance', () => { + const config = { adapter: mockAdapter } + const job = () => {} + const jobArgs = ['foo'] + const jobOptions = { wait: 300 } + const scheduler = createScheduler(config) + const spy = vi.spyOn(Scheduler.prototype, 'schedule') + + scheduler(job, jobArgs, jobOptions) + + expect(spy).toHaveBeenCalled() + }) +}) diff --git a/packages/jobs/src/core/__tests__/mocks.ts b/packages/jobs/src/core/__tests__/mocks.ts new file mode 100644 index 000000000000..0897f8abf821 --- /dev/null +++ b/packages/jobs/src/core/__tests__/mocks.ts @@ -0,0 +1,19 @@ +import { vi } from 'vitest' + +export const mockLogger = { + log: vi.fn(() => {}), + info: vi.fn(() => {}), + debug: vi.fn(() => {}), + warn: vi.fn(() => {}), + error: vi.fn(() => {}), +} + +export const mockAdapter = { + options: {}, + logger: mockLogger, + schedule: vi.fn(() => {}), + find: () => null, + clear: () => {}, + success: (_job: { handler: string; args: any }) => {}, + failure: (_job: { handler: string; args: any }, _error: Error) => {}, +} From 2bc24c6f3e8f7454bba59e840eee8e283ca2f958 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 5 Aug 2024 16:23:50 -0700 Subject: [PATCH 146/258] Moves shared files to root --- packages/jobs/src/{core => }/consts.ts | 4 ++++ packages/jobs/src/{core => }/errors.ts | 6 ++++++ packages/jobs/src/index.ts | 3 +-- packages/jobs/src/{core => }/loaders.ts | 3 +-- 4 files changed, 12 insertions(+), 4 deletions(-) rename packages/jobs/src/{core => }/consts.ts (80%) rename packages/jobs/src/{core => }/errors.ts (96%) rename packages/jobs/src/{core => }/loaders.ts (89%) diff --git a/packages/jobs/src/core/consts.ts b/packages/jobs/src/consts.ts similarity index 80% rename from packages/jobs/src/core/consts.ts rename to packages/jobs/src/consts.ts index 7d8b954b3f99..15ceade43de0 100644 --- a/packages/jobs/src/core/consts.ts +++ b/packages/jobs/src/consts.ts @@ -8,7 +8,11 @@ export const DEFAULT_SLEEP_DELAY = 5 export const DEFAULT_DELETE_FAILED_JOBS = false export const DEFAULT_LOGGER = console export const DEFAULT_QUEUE = 'default' +export const DEFAULT_WORK_QUEUE = '*' export const DEFAULT_PRIORITY = 50 +export const DEFAULT_WAIT = 0 +export const DEFAULT_WAIT_UNTIL = null +export const PROCESS_TITLE_PREFIX = 'rw-jobs-worker' /** * The name of the exported variable from the jobs config file that contains diff --git a/packages/jobs/src/core/errors.ts b/packages/jobs/src/errors.ts similarity index 96% rename from packages/jobs/src/core/errors.ts rename to packages/jobs/src/errors.ts index baad0f9f3656..eac5d9908e8a 100644 --- a/packages/jobs/src/core/errors.ts +++ b/packages/jobs/src/errors.ts @@ -142,3 +142,9 @@ export class QueueNotDefinedError extends RedwoodJobError { super('Scheduler requires a named `queue` to place jobs in') } } + +export class WorkerConfigIndexNotFoundError extends RedwoodJobError { + constructor(index: number) { + super(`Worker index ${index} not found in jobs config`) + } +} diff --git a/packages/jobs/src/index.ts b/packages/jobs/src/index.ts index 111d27e90ac7..6d66ff2590d3 100644 --- a/packages/jobs/src/index.ts +++ b/packages/jobs/src/index.ts @@ -1,4 +1,4 @@ -export * from './core/errors' +export * from './errors' export { RedwoodJob } from './core/RedwoodJob' export { Executor } from './core/Executor' @@ -8,4 +8,3 @@ export { BaseAdapter } from './adapters/BaseAdapter' export { PrismaAdapter } from './adapters/PrismaAdapter' export type { WorkerConfig } from './core/Worker' -export type { AvailableJobs } from './types' diff --git a/packages/jobs/src/core/loaders.ts b/packages/jobs/src/loaders.ts similarity index 89% rename from packages/jobs/src/core/loaders.ts rename to packages/jobs/src/loaders.ts index 82b22a5c0906..e340aeca7220 100644 --- a/packages/jobs/src/core/loaders.ts +++ b/packages/jobs/src/loaders.ts @@ -3,7 +3,6 @@ import { pathToFileURL } from 'node:url' import fg from 'fast-glob' -import { registerApiSideBabelHook } from '@redwoodjs/babel-config' import { getPaths } from '@redwoodjs/project-config' import { JobsLibNotFoundError, JobNotFoundError } from './errors' @@ -15,7 +14,7 @@ export function makeFilePath(path: string) { // Loads the named export from the app's jobs config in api/src/lib/jobs.{js,ts} // to configure the worker, defaults to `workerConfig` export const loadJobsConfig = async () => { - const jobsConfigPath = getPaths().api.jobsConfig + const jobsConfigPath = getPaths().api.distJobsConfig if (jobsConfigPath) { return require(jobsConfigPath) From 9e302e4d9a05caeab179efaae2ddc61517aa1ef4 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 5 Aug 2024 16:24:13 -0700 Subject: [PATCH 147/258] Removes unused types --- packages/jobs/src/types.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/jobs/src/types.ts b/packages/jobs/src/types.ts index d8663411f0b3..a2edcdb1a55f 100644 --- a/packages/jobs/src/types.ts +++ b/packages/jobs/src/types.ts @@ -1,5 +1,3 @@ -import type { RedwoodJob } from './core/RedwoodJob' - // Defines the basic shape of a logger that RedwoodJob will invoke to print // debug messages. RedwoodJob will fallback to use `console` if no // logger is passed in to RedwoodJob or any adapter. Luckily both Redwood's @@ -10,9 +8,3 @@ export interface BasicLogger { warn: (message?: any, ...optionalParams: any[]) => void error: (message?: any, ...optionalParams: any[]) => void } - -// The type of the `jobs` object that's exported from api/src/lib/jobs.ts that -// contains an instance of all jobs that can be run -export interface AvailableJobs { - [key: string]: RedwoodJob -} From 909e28df4980251b9e0fa89fa016dd878d5b9842 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 5 Aug 2024 16:24:26 -0700 Subject: [PATCH 148/258] Update import from moved files --- packages/jobs/src/adapters/PrismaAdapter.ts | 2 +- packages/jobs/src/core/Executor.ts | 9 ++++----- packages/jobs/src/core/Scheduler.js | 4 ++-- packages/jobs/src/core/Worker.ts | 8 ++++---- packages/jobs/src/core/__tests__/Scheduler.test.ts | 4 ++-- 5 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/jobs/src/adapters/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter.ts index 03f4866ba25e..47b5f7aa3106 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter.ts @@ -28,7 +28,7 @@ import type { PrismaClient } from '@prisma/client' import { camelCase } from 'change-case' -import { ModelNameError } from '../core/errors' +import { ModelNameError } from '../errors' import type { BaseJob, diff --git a/packages/jobs/src/core/Executor.ts b/packages/jobs/src/core/Executor.ts index e221b5e46c70..6cbace635187 100644 --- a/packages/jobs/src/core/Executor.ts +++ b/packages/jobs/src/core/Executor.ts @@ -3,15 +3,14 @@ import console from 'node:console' import type { BaseAdapter } from '../adapters/BaseAdapter' -import type { BasicLogger } from '../types' - -import { DEFAULT_MAX_ATTEMPTS, DEFAULT_DELETE_FAILED_JOBS } from './consts' +import { DEFAULT_MAX_ATTEMPTS, DEFAULT_DELETE_FAILED_JOBS } from '../consts' import { AdapterRequiredError, JobRequiredError, JobExportNotFoundError, -} from './errors' -import { loadJob } from './loaders' +} from '../errors' +import { loadJob } from '../loaders' +import type { BasicLogger } from '../types' interface Options { adapter: BaseAdapter diff --git a/packages/jobs/src/core/Scheduler.js b/packages/jobs/src/core/Scheduler.js index 767447ffa7c1..eb8b1f3db4df 100644 --- a/packages/jobs/src/core/Scheduler.js +++ b/packages/jobs/src/core/Scheduler.js @@ -3,12 +3,12 @@ import { DEFAULT_PRIORITY, DEFAULT_WAIT, DEFAULT_WAIT_UNTIL, -} from './consts' +} from '../consts' import { AdapterNotConfiguredError, QueueNotConfiguredError, SchedulingError, -} from './errors' +} from '../errors' export class Scheduler { constructor({ config, adapter, logger }) { diff --git a/packages/jobs/src/core/Worker.ts b/packages/jobs/src/core/Worker.ts index 50251b50b384..9720bee0fc37 100644 --- a/packages/jobs/src/core/Worker.ts +++ b/packages/jobs/src/core/Worker.ts @@ -5,15 +5,15 @@ import process from 'node:process' import { setTimeout } from 'node:timers' import type { BaseAdapter } from '../adapters/BaseAdapter' -import type { BasicLogger } from '../types' - import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_RUNTIME, DEFAULT_SLEEP_DELAY, DEFAULT_DELETE_FAILED_JOBS, -} from './consts' -import { AdapterRequiredError } from './errors' +} from '../consts' +import { AdapterRequiredError } from '../errors' +import type { BasicLogger } from '../types' + import { Executor } from './Executor' // The options set in api/src/lib/jobs.ts diff --git a/packages/jobs/src/core/__tests__/Scheduler.test.ts b/packages/jobs/src/core/__tests__/Scheduler.test.ts index 761215a2fc0e..1603da67c653 100644 --- a/packages/jobs/src/core/__tests__/Scheduler.test.ts +++ b/packages/jobs/src/core/__tests__/Scheduler.test.ts @@ -8,8 +8,8 @@ import { DEFAULT_QUEUE, DEFAULT_WAIT, DEFAULT_WAIT_UNTIL, -} from '../consts.ts' -import { AdapterNotConfiguredError, SchedulingError } from '../errors.ts' +} from '../../consts.ts' +import { AdapterNotConfiguredError, SchedulingError } from '../../errors.ts' import { Scheduler } from '../Scheduler.js' import { mockAdapter, mockLogger } from './mocks.ts' From 53394d6b19a177d9db64dd7ddb188e9f70da8319 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 5 Aug 2024 16:31:34 -0700 Subject: [PATCH 149/258] Just get this building --- packages/jobs/src/core/RedwoodJob.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/jobs/src/core/RedwoodJob.ts b/packages/jobs/src/core/RedwoodJob.ts index ec9e4b7a9b1a..5c33087a7eb8 100644 --- a/packages/jobs/src/core/RedwoodJob.ts +++ b/packages/jobs/src/core/RedwoodJob.ts @@ -1,6 +1,13 @@ +// @ts-ignore Who cares import { Scheduler } from './Scheduler' -class RedwoodJob { +export class RedwoodJob { + config: any + adapters: any + queues: any + logger: any + workers: any + // config looks like: // adapters: { [key: string]: BaseAdapter } // queues: string[] @@ -13,7 +20,7 @@ class RedwoodJob { // deleteFailedJobs: boolean // sleepDelay: number // count: number - constructor(config) { + constructor(config: any) { this.config = config this.adapters = config.adapters @@ -28,13 +35,14 @@ class RedwoodJob { // priority: number // wait: number (either/or) // waitUntil: Date - createScheduler(schedulerConfig) { + createScheduler(schedulerConfig: any) { const scheduler = new Scheduler({ config: schedulerConfig, adapter: this.adapters[schedulerConfig.adapter], logger: this.logger, }) + // @ts-ignore Who cares return (job, jobArgs = [], jobOptions = {}) => { return scheduler.schedule(job, jobArgs, jobOptions) } @@ -47,11 +55,7 @@ class RedwoodJob { // waitUntil: Date // perform: function // userDefinedFunction(s) - createJob(jobDefinition) { + createJob(jobDefinition: any) { return jobDefinition } - - createWorker() { - // coming soon - } } From 11484934baea59cf176dc9080386bea72d869872 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 5 Aug 2024 16:31:42 -0700 Subject: [PATCH 150/258] Updates for new worker config --- packages/jobs/src/bins/rw-jobs-worker.ts | 140 ++++--------- packages/jobs/src/bins/rw-jobs.ts | 249 ++++++++++------------- 2 files changed, 142 insertions(+), 247 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index 036785a2852a..1708d1dcddae 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -3,10 +3,6 @@ // The process that actually starts an instance of Worker to process jobs. // Can be run independently with `yarn rw-jobs-worker` but by default is forked // by `yarn rw-jobs` and either monitored, or detached to run independently. -// -// If you want to get fancy and have different workers running with different -// configurations, you need to invoke this script manually and pass the --config -// option with the name of the named export from api/src/lib/jobs.js import console from 'node:console' import process from 'node:process' @@ -18,30 +14,30 @@ import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_RUNTIME, DEFAULT_SLEEP_DELAY, - DEFAULT_ADAPTER_NAME, - DEFAULT_LOGGER_NAME, -} from '../core/consts' -import { AdapterNotFoundError, LoggerNotFoundError } from '../core/errors' -import { loadJobsConfig } from '../core/loaders' + DEFAULT_WORK_QUEUE, + DEFAULT_LOGGER, + PROCESS_TITLE_PREFIX, +} from '../consts' import { Worker } from '../core/Worker' +import { AdapterNotFoundError, WorkerConfigIndexNotFoundError } from '../errors' +import { loadJobsConfig } from '../loaders' import type { BasicLogger } from '../types' -const TITLE_PREFIX = `rw-jobs-worker` - const parseArgs = (argv: string[]) => { return yargs(hideBin(argv)) .usage( 'Starts a single RedwoodJob worker to process background jobs\n\nUsage: $0 [options]', ) - .option('id', { + .option('index', { type: 'number', - description: 'The worker ID', + description: + 'The index of the `workers` property from the exported `jobs` config to use to configure this worker', default: 0, }) - .option('queue', { - type: 'string', - description: 'The named queue to work on', - default: null, + .option('id', { + type: 'number', + description: 'The worker count id to identify this worker', + default: 0, }) .option('workoff', { type: 'boolean', @@ -53,58 +49,11 @@ const parseArgs = (argv: string[]) => { default: false, description: 'Remove all jobs in the queue and exit', }) - .option('maxAttempts', { - type: 'number', - default: DEFAULT_MAX_ATTEMPTS, - description: 'The maximum number of times a job can be attempted', - }) - .option('maxRuntime', { - type: 'number', - default: DEFAULT_MAX_RUNTIME, - description: 'The maximum number of seconds a job can run', - }) - .option('sleepDelay', { - type: 'number', - default: DEFAULT_SLEEP_DELAY, - description: - 'The maximum number of seconds to wait between polling for jobs', - }) - .option('deleteFailedJobs', { - type: 'boolean', - default: DEFAULT_DELETE_FAILED_JOBS, - description: - 'Whether to remove failed jobs from the queue after max attempts', - }) - .option('adapter', { - type: 'string', - default: DEFAULT_ADAPTER_NAME, - description: - 'Name of the exported variable from the jobs config file that contains the adapter', - }) - .option('logger', { - type: 'string', - description: - 'Name of the exported variable from the jobs config file that contains the adapter', - }) .help().argv } -const setProcessTitle = ({ - id, - queue, -}: { - id: number - queue: string | null -}) => { - // set the process title - let title = TITLE_PREFIX - if (queue) { - title += `.${queue}.${id}` - } else { - title += `.${id}` - } - - process.title = title +const setProcessTitle = ({ id, queue }: { id: number; queue: string }) => { + process.title = `${PROCESS_TITLE_PREFIX}.${queue}.${id}` } const setupSignals = ({ @@ -136,63 +85,50 @@ const setupSignals = ({ } const main = async () => { - const { - id, - queue, - clear, - workoff, - maxAttempts, - maxRuntime, - sleepDelay, - deleteFailedJobs, - adapter: adapterName, - logger: loggerName, - } = await parseArgs(process.argv) - setProcessTitle({ id, queue }) + const { index, id, clear, workoff } = await parseArgs(process.argv) let jobsConfig - // Pull the complex config options we can't pass on the command line directly - // from the app's jobs config file: `adapter` and `logger`. Remaining config - // is passed as command line flags. The rw-jobs script pulls THOSE config - // options from the jobs config, but if you're not using that script you need - // to pass manually. Calling this script directly is ADVANCED USAGE ONLY! try { - jobsConfig = await loadJobsConfig() + jobsConfig = (await loadJobsConfig()).jobs } catch (e) { console.error(e) process.exit(1) } - // Exit if the named adapter isn't exported - if (!jobsConfig[adapterName]) { - throw new AdapterNotFoundError(adapterName) + const workerConfig = jobsConfig.workers[index] + + // Exit if the indexed worker options doesn't exist + if (!workerConfig) { + throw new WorkerConfigIndexNotFoundError(index) } - // Exit if the named logger isn't exported (if one was provided) - if (loggerName && !jobsConfig[loggerName]) { - throw new LoggerNotFoundError(loggerName) + const adapter = jobsConfig.adapters[workerConfig.adapter] + + // Exit if the named adapter isn't exported + if (!adapter) { + throw new AdapterNotFoundError(workerConfig.adapter) } - // if a named logger was provided, use it, otherwise fall back to the default - // name, otherwise just use the console - const logger = loggerName - ? jobsConfig[loggerName] - : jobsConfig[DEFAULT_LOGGER_NAME] || console + // Use worker logger, or jobs worker, or fallback to console + const logger = workerConfig.logger ?? jobsConfig.logger ?? DEFAULT_LOGGER logger.info( `[${process.title}] Starting work at ${new Date().toISOString()}...`, ) + setProcessTitle({ id, queue: workerConfig.queue }) + const worker = new Worker({ - adapter: jobsConfig[adapterName], + adapter, logger, - maxAttempts, - maxRuntime, - sleepDelay, - deleteFailedJobs, + maxAttempts: workerConfig.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, + maxRuntime: workerConfig.maxRuntime ?? DEFAULT_MAX_RUNTIME, + sleepDelay: workerConfig.sleepDelay ?? DEFAULT_SLEEP_DELAY, + deleteFailedJobs: + workerConfig.deleteFailedJobs ?? DEFAULT_DELETE_FAILED_JOBS, processName: process.title, - queue, + queue: workerConfig.queue ?? DEFAULT_WORK_QUEUE, workoff, clear, }) diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index a980196cef4a..5f83c11d3603 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -14,11 +14,11 @@ import yargs from 'yargs/yargs' import { loadEnvFiles } from '@redwoodjs/cli-helpers/dist/lib/loadEnvFiles.js' -import { loadJobsConfig } from '../core/loaders' -import type { WorkerConfig } from '../core/Worker' +import { DEFAULT_LOGGER, PROCESS_TITLE_PREFIX } from '../consts' +import { loadJobsConfig } from '../loaders' import type { BasicLogger } from '../types' -export type NumWorkersConfig = Array<[string | null, number]> // [queue, id] +export type NumWorkersConfig = Array<[number, number]> loadEnvFiles() @@ -31,45 +31,9 @@ const parseArgs = (argv: string[]) => { ) .command('work', 'Start a worker and process jobs') .command('workoff', 'Start a worker and exit after all jobs processed') - .command('start', 'Start workers in daemon mode', (yargs) => { - yargs - .option('n', { - type: 'string', - describe: - 'Number of workers to start OR queue:num pairs of workers to start (see examples)', - default: '1', - }) - .example( - '$0 start -n 2', - 'Start the job runner with 2 workers in daemon mode', - ) - .example( - '$0 start -n default:2,email:1', - 'Start the job runner in daemon mode with 2 workers for the "default" queue and 1 for the "email" queue', - ) - }) + .command('start', 'Start workers in daemon mode') .command('stop', 'Stop any daemonized job workers') - .command( - 'restart', - 'Stop and start any daemonized job workers', - (yargs) => { - yargs - .option('n', { - type: 'string', - describe: - 'Number of workers to start OR queue:num pairs of workers to start (see examples)', - default: '1', - }) - .example( - '$0 restart -n 2', - 'Restart the job runner with 2 workers in daemon mode', - ) - .example( - '$0 restart -n default:2,email:1', - 'Restart the job runner in daemon mode with 2 workers for the `default` queue and 1 for the `email` queue', - ) - }, - ) + .command('restart', 'Stop and start any daemonized job workers') .command('clear', 'Clear the job queue') .demandCommand(1, 'You must specify a mode to start in') .example( @@ -82,31 +46,39 @@ const parseArgs = (argv: string[]) => { ) .help().argv - return { workerDef: parsed.n, command: parsed._[0] } + return { command: parsed._[0] } } -const buildNumWorkers = (workerDef: string): NumWorkersConfig => { - // Builds up an array of arrays, with queue name and id: - // `-n default:2,email:1` => [ ['default', 0], ['default', 1], ['email', 0] ] - // If only given a number of workers then queue name is null (all queues): - // `-n 2` => [ [null, 0], [null, 1] ] - const workers: NumWorkersConfig = [] - - // default to one worker for commands that don't specify - if (!workerDef) { - workerDef = '1' - } - - // if only a number was given, convert it to a nameless worker: `2` => `:2` - if (!isNaN(parseInt(workerDef))) { - workerDef = `:${workerDef}` - } - - // split the queue:num pairs and build the workers array - workerDef.split(',').forEach((count: string) => { - const [queue, num] = count.split(':') - for (let i = 0; i < parseInt(num); i++) { - workers.push([queue || null, i]) +// Builds up an array of arrays, with the worker config index and id based on +// how many workers should use this config. For the following config: +// +// { +// workers: [ +// { +// adapter: 'prisma', +// count: 2, +// queue: 'default', +// }, +// { +// adapter: 'prisma', +// count: 1, +// queue: 'email', +// }, +// ] +// } +// +// The output would be: +// +// [ +// [0, 0], +// [0, 1], +// [1, 0], +// ] +const buildNumWorkers = (config: any) => { + // @ts-ignore who cares + const workers = config.map((worker: any, index: number) => { + for (let id = 0; id < worker.count; id++) { + return [index, id] } }) @@ -115,47 +87,27 @@ const buildNumWorkers = (workerDef: string): NumWorkersConfig => { const startWorkers = ({ numWorkers, - workerConfig = {}, detach = false, workoff = false, logger, }: { numWorkers: NumWorkersConfig - workerConfig: WorkerConfig detach?: boolean workoff?: boolean logger: BasicLogger }) => { logger.warn(`Starting ${numWorkers.length} worker(s)...`) - return numWorkers.map(([queue, id]) => { + return numWorkers.map(([index, id]) => { // list of args to send to the forked worker script - const workerArgs: string[] = ['--id', id.toString()] - - if (queue) { - workerArgs.push('--queue', queue) - } + const workerArgs: string[] = [] + workerArgs.push('--index', index.toString()) + workerArgs.push('--id', id.toString()) if (workoff) { workerArgs.push('--workoff') } - if (workerConfig.maxAttempts) { - workerArgs.push('--max-attempts', workerConfig.maxAttempts.toString()) - } - - if (workerConfig.maxRuntime) { - workerArgs.push('--max-runtime', workerConfig.maxRuntime.toString()) - } - - if (workerConfig.deleteFailedJobs) { - workerArgs.push('--delete-failed-jobs') - } - - if (workerConfig.sleepDelay) { - workerArgs.push('--sleep-delay', workerConfig.sleepDelay.toString()) - } - // fork the worker process // TODO squiggles under __dirname, but import.meta.dirname blows up when running the process const worker = fork(path.join(__dirname, 'rw-jobs-worker.js'), workerArgs, { @@ -175,6 +127,49 @@ const startWorkers = ({ }) } +// TODO add support for stopping with SIGTERM or SIGKILL? +const stopWorkers = async ({ + numWorkers, + // @ts-ignore who cares + workerConfig, + signal = 'SIGINT', + logger, +}: { + numWorkers: NumWorkersConfig + signal: string + logger: BasicLogger +}) => { + logger.warn( + `Stopping ${numWorkers.length} worker(s) gracefully (${signal})...`, + ) + + for (const [index, id] of numWorkers) { + const queue = workerConfig[index].queue + const workerTitle = `${PROCESS_TITLE_PREFIX}${queue ? `.${queue}` : ''}.${id}` + const processId = await findProcessId(workerTitle) + + if (!processId) { + logger.warn(`No worker found with title ${workerTitle}`) + continue + } + + logger.info( + `Stopping worker ${workerTitle} with process id ${processId}...`, + ) + process.kill(processId, signal) + + // wait for the process to actually exit before going to next iteration + while (await findProcessId(workerTitle)) { + await new Promise((resolve) => setTimeout(resolve, 250)) + } + } +} + +const clearQueue = ({ logger }: { logger: BasicLogger }) => { + logger.warn(`Starting worker to clear job queue...`) + fork(path.join(__dirname, 'worker.js'), ['--clear']) +} + const signalSetup = ({ workers, logger, @@ -240,76 +235,36 @@ const findProcessId = async (name: string): Promise => { }) } -// TODO add support for stopping with SIGTERM or SIGKILL? -const stopWorkers = async ({ - numWorkers, - signal = 'SIGINT', - logger, -}: { - numWorkers: NumWorkersConfig - signal: string - logger: BasicLogger -}) => { - logger.warn( - `Stopping ${numWorkers.length} worker(s) gracefully (${signal})...`, - ) - - for (const [queue, id] of numWorkers) { - const workerTitle = `rw-jobs-worker${queue ? `.${queue}` : ''}.${id}` - const processId = await findProcessId(workerTitle) - - if (!processId) { - logger.warn(`No worker found with title ${workerTitle}`) - continue - } - - logger.info( - `Stopping worker ${workerTitle} with process id ${processId}...`, - ) - process.kill(processId, signal) - - // wait for the process to actually exit before going to next iteration - while (await findProcessId(workerTitle)) { - await new Promise((resolve) => setTimeout(resolve, 250)) - } - } -} - -const clearQueue = ({ logger }: { logger: BasicLogger }) => { - logger.warn(`Starting worker to clear job queue...`) - fork(path.join(__dirname, 'worker.js'), ['--clear']) -} - const main = async () => { - const { workerDef, command } = parseArgs(process.argv) - const numWorkers = buildNumWorkers(workerDef) - - // get the worker config defined in the app's job config file - const jobsConfig = await loadJobsConfig() - let logger - - if (jobsConfig.logger) { - logger = jobsConfig.logger - } else { - logger = console + const { command } = parseArgs(process.argv) + let jobsConfig + + try { + jobsConfig = (await loadJobsConfig()).jobs + } catch (e) { + console.error(e) + process.exit(1) } + const workerConfig = jobsConfig.workers + const numWorkers = buildNumWorkers(workerConfig) + const logger = jobsConfig.logger ?? DEFAULT_LOGGER + logger.warn(`Starting RedwoodJob Runner at ${new Date().toISOString()}...`) switch (command) { case 'start': startWorkers({ numWorkers, - workerConfig: jobsConfig.workerConfig, detach: true, logger, }) return process.exit(0) case 'restart': - await stopWorkers({ numWorkers, signal: 'SIGINT', logger }) + // @ts-ignore who cares + await stopWorkers({ numWorkers, workerConfig, signal: 'SIGINT', logger }) startWorkers({ numWorkers, - workerConfig: jobsConfig.workerConfig, detach: true, logger, }) @@ -318,7 +273,6 @@ const main = async () => { return signalSetup({ workers: startWorkers({ numWorkers, - workerConfig: jobsConfig.workerConfig, logger, }), logger, @@ -327,14 +281,19 @@ const main = async () => { return signalSetup({ workers: startWorkers({ numWorkers, - workerConfig: jobsConfig.workerConfig, workoff: true, logger, }), logger, }) case 'stop': - return await stopWorkers({ numWorkers, signal: 'SIGINT', logger }) + return await stopWorkers({ + numWorkers, + // @ts-ignore who cares + workerConfig, + signal: 'SIGINT', + logger, + }) case 'clear': return clearQueue({ logger }) } From 35bddeb95fe0ddbd2ff157c373c8a62916f2ead4 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Sat, 3 Aug 2024 01:08:53 +0100 Subject: [PATCH 151/258] initial typing stuff --- packages/jobs/src/adapters/BaseAdapter.ts | 2 +- packages/jobs/src/core/JobManager.ts | 55 +++++ packages/jobs/src/core/RedwoodJob.ts | 57 ----- packages/jobs/src/core/Scheduler.js | 75 ------ packages/jobs/src/core/Scheduler.ts | 94 ++++++++ packages/jobs/src/core/consts.ts | 5 + packages/jobs/src/core/loaders.ts | 1 - packages/jobs/src/index.ts | 5 +- packages/jobs/src/types.ts | 268 +++++++++++++++++++++- 9 files changed, 420 insertions(+), 142 deletions(-) create mode 100644 packages/jobs/src/core/JobManager.ts delete mode 100644 packages/jobs/src/core/RedwoodJob.ts delete mode 100644 packages/jobs/src/core/Scheduler.js create mode 100644 packages/jobs/src/core/Scheduler.ts diff --git a/packages/jobs/src/adapters/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter.ts index b23ce5033c11..39538f87aec5 100644 --- a/packages/jobs/src/adapters/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter.ts @@ -12,7 +12,7 @@ import type { BasicLogger } from '../types' // Arguments sent to an adapter to schedule a job export interface SchedulePayload { job: string - args: unknown + args: unknown[] runAt: Date queue: string priority: number diff --git a/packages/jobs/src/core/JobManager.ts b/packages/jobs/src/core/JobManager.ts new file mode 100644 index 000000000000..b1d83ec45c34 --- /dev/null +++ b/packages/jobs/src/core/JobManager.ts @@ -0,0 +1,55 @@ +import type { + Adapters, + BasicLogger, + CreateSchedulerConfig, + Job, + JobDefinition, + JobManagerConfig, + ScheduleJobOptions, + WorkerConfig, +} from '../types' + +import { Scheduler } from './Scheduler' + +export class JobManager< + TAdapters extends Adapters, + TQueues extends string[], + TLogger extends BasicLogger, +> { + adapters: TAdapters + queues: TQueues + logger: TLogger + workers: WorkerConfig[] + + constructor(config: JobManagerConfig) { + this.adapters = config.adapters + this.queues = config.queues + this.logger = config.logger + this.workers = config.workers + } + + createScheduler(schedulerConfig: CreateSchedulerConfig) { + const scheduler = new Scheduler({ + adapter: this.adapters[schedulerConfig.adapter], + logger: this.logger, + }) + + return >( + job: T, + jobArgs?: Parameters, + jobOptions?: ScheduleJobOptions, + ) => { + return scheduler.schedule({ job, jobArgs, jobOptions }) + } + } + + createJob( + jobDefinition: JobDefinition, + ): Job { + return jobDefinition + } + + createWorker() { + // coming soon + } +} diff --git a/packages/jobs/src/core/RedwoodJob.ts b/packages/jobs/src/core/RedwoodJob.ts deleted file mode 100644 index ec9e4b7a9b1a..000000000000 --- a/packages/jobs/src/core/RedwoodJob.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Scheduler } from './Scheduler' - -class RedwoodJob { - // config looks like: - // adapters: { [key: string]: BaseAdapter } - // queues: string[] - // logger: BasicLogger - // workers: WorkerConfig[] - // adapter: keyof this.config.adapters - // queue: oneof this.config.queues - // maxAttempts: number - // maxRuntime: number - // deleteFailedJobs: boolean - // sleepDelay: number - // count: number - constructor(config) { - this.config = config - - this.adapters = config.adapters - this.queues = config.queues - this.logger = config.logger - this.workers = config.workers - } - - // schedulerConfig: - // adapter: keyof this.config.adapters - // queue: oneof this.config.queues - // priority: number - // wait: number (either/or) - // waitUntil: Date - createScheduler(schedulerConfig) { - const scheduler = new Scheduler({ - config: schedulerConfig, - adapter: this.adapters[schedulerConfig.adapter], - logger: this.logger, - }) - - return (job, jobArgs = [], jobOptions = {}) => { - return scheduler.schedule(job, jobArgs, jobOptions) - } - } - - // jobDefinition looks like: - // queue: one of this.config.queues - // priority: number - // wait: number (either/or) - // waitUntil: Date - // perform: function - // userDefinedFunction(s) - createJob(jobDefinition) { - return jobDefinition - } - - createWorker() { - // coming soon - } -} diff --git a/packages/jobs/src/core/Scheduler.js b/packages/jobs/src/core/Scheduler.js deleted file mode 100644 index 767447ffa7c1..000000000000 --- a/packages/jobs/src/core/Scheduler.js +++ /dev/null @@ -1,75 +0,0 @@ -import { - DEFAULT_LOGGER, - DEFAULT_PRIORITY, - DEFAULT_WAIT, - DEFAULT_WAIT_UNTIL, -} from './consts' -import { - AdapterNotConfiguredError, - QueueNotConfiguredError, - SchedulingError, -} from './errors' - -export class Scheduler { - constructor({ config, adapter, logger }) { - this.config = config - this.logger = logger ?? DEFAULT_LOGGER - this.adapter = adapter - - if (!this.adapter) { - throw new AdapterNotConfiguredError() - } - } - - computeRunAt(wait, waitUntil) { - if (wait && wait > 0) { - return new Date(new Date().getTime() + wait * 1000) - } else if (waitUntil) { - return waitUntil - } else { - return new Date() - } - } - - buildPayload(job, args, options) { - const queue = options.queue ?? job.queue ?? this.config.queue - const priority = - options.priority ?? - job.priority ?? - this.config.priority ?? - DEFAULT_PRIORITY - const wait = options.wait ?? job.wait ?? DEFAULT_WAIT - const waitUntil = options.waitUntil ?? job.waitUntil ?? DEFAULT_WAIT_UNTIL - - if (!queue) { - throw new QueueNotConfiguredError() - } - - return { - job: job.name, - args: args, - runAt: this.computeRunAt(wait, waitUntil), - queue: queue, - priority: priority, - } - } - - async schedule({ job, jobArgs = [], jobOptions = {} } = {}) { - const payload = this.buildPayload(job, jobArgs, jobOptions) - - this.logger.info( - payload, - `[RedwoodJob] Scheduling ${this.constructor.name}`, - ) - - try { - this.adapter.schedule(payload) - return true - } catch (e) { - throw new SchedulingError( - `[RedwoodJob] Exception when scheduling ${payload.name}`, - e, - ) - } - } -} diff --git a/packages/jobs/src/core/Scheduler.ts b/packages/jobs/src/core/Scheduler.ts new file mode 100644 index 000000000000..f606bbbee4eb --- /dev/null +++ b/packages/jobs/src/core/Scheduler.ts @@ -0,0 +1,94 @@ +import type { BaseAdapter, SchedulePayload } from '../adapters/BaseAdapter' +import type { BasicLogger, Job, ScheduleJobOptions } from '../types' + +import { + DEFAULT_LOGGER, + DEFAULT_PRIORITY, + DEFAULT_WAIT, + DEFAULT_WAIT_UNTIL, +} from './consts' +import { + AdapterNotConfiguredError, + QueueNotDefinedError, + SchedulingError, +} from './errors' + +interface SchedulerConfig { + adapter: TAdapter + logger?: BasicLogger +} + +export class Scheduler { + adapter: TAdapter + logger: BasicLogger + + constructor({ adapter, logger }: SchedulerConfig) { + this.logger = logger ?? DEFAULT_LOGGER + this.adapter = adapter + + if (!this.adapter) { + throw new AdapterNotConfiguredError() + } + } + + computeRunAt(wait: number, waitUntil: Date) { + if (wait && wait > 0) { + return new Date(new Date().getTime() + wait * 1000) + } else if (waitUntil) { + return waitUntil + } else { + return new Date() + } + } + + buildPayload>( + job: T, + args?: Parameters, + options?: ScheduleJobOptions, + ): SchedulePayload { + const queue = job.queue + const priority = job.priority ?? DEFAULT_PRIORITY + const wait = options?.wait ?? DEFAULT_WAIT + const waitUntil = options?.waitUntil ?? DEFAULT_WAIT_UNTIL + + if (!queue) { + throw new QueueNotDefinedError() + } + + return { + // @ts-expect-error(jgmw): Fix this + job: job.name as string, + args: args ?? [], + runAt: this.computeRunAt(wait, waitUntil), + queue: queue, + priority: priority, + } + } + + async schedule>({ + job, + jobArgs, + jobOptions, + }: { + job: T + jobArgs?: Parameters + jobOptions?: ScheduleJobOptions + }) { + const payload = this.buildPayload(job, jobArgs, jobOptions) + + this.logger.info( + payload, + `[RedwoodJob] Scheduling ${this.constructor.name}`, + ) + + try { + this.adapter.schedule(payload) + return true + } catch (e) { + throw new SchedulingError( + `[RedwoodJob] Exception when scheduling ${payload.job}`, + e as Error, + ) + } + } +} diff --git a/packages/jobs/src/core/consts.ts b/packages/jobs/src/core/consts.ts index 7d8b954b3f99..512e0c7ccd37 100644 --- a/packages/jobs/src/core/consts.ts +++ b/packages/jobs/src/core/consts.ts @@ -10,6 +10,11 @@ export const DEFAULT_LOGGER = console export const DEFAULT_QUEUE = 'default' export const DEFAULT_PRIORITY = 50 +// TODO(jgmw): Confirm this +export const DEFAULT_WAIT = 0 +// TODO(jgmw): Confirm this +export const DEFAULT_WAIT_UNTIL = new Date(0) + /** * The name of the exported variable from the jobs config file that contains * the adapter diff --git a/packages/jobs/src/core/loaders.ts b/packages/jobs/src/core/loaders.ts index 82b22a5c0906..b1fad8e8a592 100644 --- a/packages/jobs/src/core/loaders.ts +++ b/packages/jobs/src/core/loaders.ts @@ -3,7 +3,6 @@ import { pathToFileURL } from 'node:url' import fg from 'fast-glob' -import { registerApiSideBabelHook } from '@redwoodjs/babel-config' import { getPaths } from '@redwoodjs/project-config' import { JobsLibNotFoundError, JobNotFoundError } from './errors' diff --git a/packages/jobs/src/index.ts b/packages/jobs/src/index.ts index 111d27e90ac7..620a42e2351b 100644 --- a/packages/jobs/src/index.ts +++ b/packages/jobs/src/index.ts @@ -1,6 +1,6 @@ export * from './core/errors' -export { RedwoodJob } from './core/RedwoodJob' +export { JobManager } from './core/JobManager' export { Executor } from './core/Executor' export { Worker } from './core/Worker' @@ -8,4 +8,5 @@ export { BaseAdapter } from './adapters/BaseAdapter' export { PrismaAdapter } from './adapters/PrismaAdapter' export type { WorkerConfig } from './core/Worker' -export type { AvailableJobs } from './types' + +// TODO(jgmw): We tend to avoid wanting to barrel export everything diff --git a/packages/jobs/src/types.ts b/packages/jobs/src/types.ts index d8663411f0b3..3127413762cc 100644 --- a/packages/jobs/src/types.ts +++ b/packages/jobs/src/types.ts @@ -1,8 +1,9 @@ -import type { RedwoodJob } from './core/RedwoodJob' - // Defines the basic shape of a logger that RedwoodJob will invoke to print // debug messages. RedwoodJob will fallback to use `console` if no // logger is passed in to RedwoodJob or any adapter. Luckily both Redwood's + +import type { BaseAdapter } from './adapters/BaseAdapter' + // Logger and the standard console logger conform to this shape. export interface BasicLogger { debug: (message?: any, ...optionalParams: any[]) => void @@ -11,8 +12,263 @@ export interface BasicLogger { error: (message?: any, ...optionalParams: any[]) => void } -// The type of the `jobs` object that's exported from api/src/lib/jobs.ts that -// contains an instance of all jobs that can be run -export interface AvailableJobs { - [key: string]: RedwoodJob +export type Adapters = Record + +export interface WorkerConfig { + /** + * The name of the adapter to use for this worker. This must be one of the keys + * in the `adapters` object when you created the `JobManager`. + */ + adapter: keyof TAdapters + + /** + * The queue or queues that this worker should work on. You can pass a single + * queue name, an array of queue names, or the string `'*'` to work on all + * queues. + */ + queue: '*' | TQueues[number] | TQueues[number][] + + /** + * The maximum number of retries to attempt for a job before giving up. + * + * @default 24 + */ + maxAttempts?: number + + /** + * The maximum amount of time in seconds that a job can run before another + * worker will attempt to retry it. + * + * @default 14,400 (4 hours) + */ + maxRuntime?: number + + /** + * Whether a job that exceeds its `maxAttempts` should be deleted from the + * queue. If `false`, the job will remain in the queue but will not be + * processed further. + * + * @default false + */ + deleteFailedJobs?: boolean + + /** + * The amount of time in seconds to wait between polling the queue for new + * jobs. Some adapters may not need this if they do not poll the queue and + * instead rely on a subscription model. + * + * @default 5 + */ + sleepDelay?: number + + /** + * The number of workers to spawn for this worker configuration. + * + * @default 1 + */ + count?: number + + /** + * The logger to use for this worker. If not provided, the logger from the + * `JobManager` will be used. + */ + logger?: BasicLogger +} + +export interface JobManagerConfig< + // + TAdapters extends Adapters, + TQueues extends string[], + TLogger extends BasicLogger, + // +> { + /** + * An object containing all of the adapters that this job manager will use. + * The keys should be the names of the adapters and the values should be the + * adapter instances. + */ + adapters: TAdapters + + /** + * The logger to use for this job manager. If not provided, the logger will + * default to the console. + */ + logger: TLogger + + /** + * An array of all of queue names that jobs can be scheduled on to. Workers can + * be configured to work on a selection of these queues. + */ + queues: TQueues + + /** + * An array of worker configurations that define how jobs should be processed. + */ + workers: WorkerConfig[] } + +export interface CreateSchedulerConfig { + /** + * The name of the adapter to use for this scheduler. This must be one of the keys + * in the `adapters` object when you created the `JobManager`. + */ + adapter: keyof TAdapters + + /** + * The logger to use for this scheduler. If not provided, the logger from the + * `JobManager` will be used. + */ + logger?: BasicLogger +} + +export interface JobDefinition< + TQueues extends string[], + TArgs extends unknown[] = [], +> { + /** + * The name of the queue that this job should always be scheduled on. This defaults + * to the queue that the scheduler was created with, but can be overridden when + * scheduling a job. + */ + queue: TQueues[number] + + /** + * The priority of the job in the range of 0-100. The lower the number, the + * higher the priority. The default is 50. + * @default 50 + */ + priority?: PriorityValue + + /** + * The function to run when this job is executed. + * + * @param args The arguments that were passed when the job was scheduled. + */ + perform: (...args: TArgs) => Promise | void +} + +export type Job< + TQueues extends string[], + TArgs extends unknown[] = [], +> = JobDefinition + +export type ScheduleJobOptions = + | { + /** + * The number of seconds to wait before scheduling this job. This is mutually + * exclusive with `waitUntil`. + */ + wait: number + waitUntil?: never + } + | { + wait?: never + /** + * The date and time to schedule this job for. This is mutually exclusive with + * `wait`. + */ + waitUntil: Date + } + +type PriorityValue = + | 0 + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 8 + | 9 + | 10 + | 11 + | 12 + | 13 + | 14 + | 15 + | 16 + | 17 + | 18 + | 19 + | 20 + | 21 + | 22 + | 23 + | 24 + | 25 + | 26 + | 27 + | 28 + | 29 + | 30 + | 31 + | 32 + | 33 + | 34 + | 35 + | 36 + | 37 + | 38 + | 39 + | 40 + | 41 + | 42 + | 43 + | 44 + | 45 + | 46 + | 47 + | 48 + | 49 + | 50 + | 51 + | 52 + | 53 + | 54 + | 55 + | 56 + | 57 + | 58 + | 59 + | 60 + | 61 + | 62 + | 63 + | 64 + | 65 + | 66 + | 67 + | 68 + | 69 + | 70 + | 71 + | 72 + | 73 + | 74 + | 75 + | 76 + | 77 + | 78 + | 79 + | 80 + | 81 + | 82 + | 83 + | 84 + | 85 + | 86 + | 87 + | 88 + | 89 + | 90 + | 91 + | 92 + | 93 + | 94 + | 95 + | 96 + | 97 + | 98 + | 99 + | 100 From 08f1b9adde1c0ba649fcb8f904816f04a8eb1ff4 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Tue, 6 Aug 2024 02:45:30 +0100 Subject: [PATCH 152/258] basic case plugin --- packages/babel-config/src/api.ts | 7 ++ .../babel-plugin-redwood-job-path-injector.ts | 110 ++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 packages/babel-config/src/plugins/babel-plugin-redwood-job-path-injector.ts diff --git a/packages/babel-config/src/api.ts b/packages/babel-config/src/api.ts index 163376b5c6a9..a83a3002355e 100644 --- a/packages/babel-config/src/api.ts +++ b/packages/babel-config/src/api.ts @@ -20,6 +20,7 @@ import pluginRedwoodContextWrapping from './plugins/babel-plugin-redwood-context import pluginRedwoodDirectoryNamedImport from './plugins/babel-plugin-redwood-directory-named-import' import pluginRedwoodGraphqlOptionsExtract from './plugins/babel-plugin-redwood-graphql-options-extract' import pluginRedwoodImportDir from './plugins/babel-plugin-redwood-import-dir' +import pluginRedwoodJobPathInjector from './plugins/babel-plugin-redwood-job-path-injector' import pluginRedwoodOTelWrapping from './plugins/babel-plugin-redwood-otel-wrapping' export const TARGETS_NODE = '20.10' @@ -178,6 +179,12 @@ export const getApiSideBabelOverrides = ({ projectIsEsm = false } = {}) => { ], ], }, + // Add import names and paths to job definitions + { + // match */api/src/jobs/*.js|ts + test: /.+api(?:[\\|/])src(?:[\\|/])jobs(?:[\\|/]).+.(?:js|ts)$/, + plugins: [[pluginRedwoodJobPathInjector]], + }, ].filter(Boolean) return overrides as TransformOptions[] } diff --git a/packages/babel-config/src/plugins/babel-plugin-redwood-job-path-injector.ts b/packages/babel-config/src/plugins/babel-plugin-redwood-job-path-injector.ts new file mode 100644 index 000000000000..1af811227442 --- /dev/null +++ b/packages/babel-config/src/plugins/babel-plugin-redwood-job-path-injector.ts @@ -0,0 +1,110 @@ +import fsPath from 'node:path' + +import type { PluginObj, types } from '@babel/core' + +import { getPaths } from '@redwoodjs/project-config' + +// This plugin is responsible for injecting the import path and name of a job +// into the object that is passed to createJob. This is later used by adapters +// and workers to import the job. + +export default function ({ types: _t }: { types: typeof types }): PluginObj { + const paths = getPaths() + return { + name: 'babel-plugin-redwood-job-path-injector', + visitor: { + ExportNamedDeclaration(path, state) { + // Extract the variable declaration from the export + const declaration = path.node.declaration + if (!declaration) { + return + } + if (declaration.type !== 'VariableDeclaration') { + return + } + // Extract the variable declarator from the declaration + const declarator = declaration.declarations[0] + if (!declarator) { + return + } + if (declarator.type !== 'VariableDeclarator') { + return + } + + // Confirm that the init it a call expression + const init = declarator.init + if (!init) { + return + } + if (init.type !== 'CallExpression') { + return + } + // Confirm that the callee is a member expression + const callee = init.callee + if (!callee) { + return + } + if (callee.type !== 'MemberExpression') { + return + } + // The object is imported and so could be aliased so lets check the property + const property = callee.property + if (!property) { + return + } + if (property.type !== 'Identifier') { + return + } + if (property.name !== 'createJob') { + return + } + + // From this point on we're confident that we're looking at a createJob call + // so let's start throwing errors if we don't find what we expect + + // Extract the variable name from the declarator + const id = declarator.id + if (!id) { + return + } + if (id.type !== 'Identifier') { + return + } + + const filepath = state.file.opts.filename + if (!filepath) { + throw new Error('No file path was found in the state') + } + + const importName = id.name + const importPath = fsPath.relative(paths.api.jobs, filepath) + const importPathWithoutExtension = importPath.replace(/\.[^/.]+$/, '') + + // Get the first argument of the call expression + const firstArg = init.arguments[0] + if (!firstArg) { + throw new Error('No first argument found in the createJob call') + } + // confirm it's an object expression + if (firstArg.type !== 'ObjectExpression') { + throw new Error( + 'The first argument of the createJob call is not an object expression', + ) + } + // Add a property to the object expression + firstArg.properties.push( + _t.objectProperty( + _t.identifier('path'), + _t.stringLiteral(importPathWithoutExtension), + ), + ) + firstArg.properties.push( + _t.objectProperty( + _t.identifier('name'), + _t.stringLiteral(importName), + ), + ) + }, + }, + } +} From 0f0d1495504be6385fcf9f705e78c79bc0bfc3b9 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 7 Aug 2024 16:16:14 -0700 Subject: [PATCH 153/258] Update jobs setup template --- .../setup/jobs/templates/jobs.ts.template | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template b/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template index 053aa33ef28c..fc7b73f78e5f 100644 --- a/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template +++ b/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template @@ -2,27 +2,31 @@ // Execute jobs in dev with `yarn rw jobs work` // See https://docs.redwoodjs.com/docs/background-jobs -import { PrismaAdapter, RedwoodJob } from '@redwoodjs/jobs' -import type { AvailableJobs, BaseAdapter, WorkerConfig } from '@redwoodjs/jobs' +import { PrismaAdapter, JobManager } from '@redwoodjs/jobs' import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' -// `adapter`, `logger` and `workerConfig` exports are used by the job workers, -// can override on a per-worker basis: -// https://docs.redwoodjs.com/docs/background-jobs#worker-configuration -export const adapter: BaseAdapter = new PrismaAdapter({ db, logger }) -export { logger } +export const jobs = new JobManager({ + adapters: { + prisma: new PrismaAdapter({ db, logger }), + }, + queues: ['default'] as const, + logger, + workers: [ + { + adapter: 'prisma', + logger, + queue: '*', + count: 1, + maxAttempts: 24, + maxRuntime: 14_400, + deleteFailedJobs: false, + sleepDelay: 5, + }, + ], +}) -export const workerConfig: WorkerConfig = { - maxAttempts: 24, - maxRuntime: 14_400, // 4 hours - sleepDelay: 5, - deleteFailedJobs: false, -} - -// Global config for all jobs, can override on a per-job basis: -// https://docs.redwoodjs.com/docs/background-jobs#job-configuration -RedwoodJob.config({ adapter, logger }) - -export const jobs: AvailableJobs = {} +export const later = jobs.createScheduler({ + adapter: 'prisma', +}) From 588500fe2f2ddcb598932ca0704c29005e1cbcd1 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 7 Aug 2024 16:16:33 -0700 Subject: [PATCH 154/258] =?UTF-8?q?Sort=20of=20fix=20loadEnvFiles=3F=20?= =?UTF-8?q?=F0=9F=98=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli-helpers/src/lib/loadEnvFiles.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli-helpers/src/lib/loadEnvFiles.ts b/packages/cli-helpers/src/lib/loadEnvFiles.ts index 95f70afd8d39..86646f34ddda 100644 --- a/packages/cli-helpers/src/lib/loadEnvFiles.ts +++ b/packages/cli-helpers/src/lib/loadEnvFiles.ts @@ -17,7 +17,10 @@ export function loadEnvFiles() { loadDefaultEnvFiles(base) loadNodeEnvDerivedEnvFile(base) - const { loadEnvFiles } = Parser.default(hideBin(process.argv), { + // TODO(@jgmw): what the hell is going on here? + // @ts-expect-error Used to be Parser.default but that blows up when starting + // job worker coordinator + const { loadEnvFiles } = Parser(hideBin(process.argv), { array: ['load-env-files'], default: { loadEnvFiles: [], From 3e691baa67b6da885205026b5ae18040f26efbca Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 7 Aug 2024 16:16:44 -0700 Subject: [PATCH 155/258] NodeNext --- packages/jobs/tsconfig.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/jobs/tsconfig.json b/packages/jobs/tsconfig.json index be2bbd94092e..ababfe904bfe 100644 --- a/packages/jobs/tsconfig.json +++ b/packages/jobs/tsconfig.json @@ -3,6 +3,8 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", + "moduleResolution": "NodeNext", + "module": "NodeNext" }, "include": ["src/**/*"], "references": [ From b98a8d4f6dfde3991eb36f237977c986881d231b Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 7 Aug 2024 16:17:17 -0700 Subject: [PATCH 156/258] ts-expect-error on CJS/ESM stuff --- packages/jobs/src/errors.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/jobs/src/errors.ts b/packages/jobs/src/errors.ts index eac5d9908e8a..8cb5a287164a 100644 --- a/packages/jobs/src/errors.ts +++ b/packages/jobs/src/errors.ts @@ -1,3 +1,4 @@ +// @ts-expect-error - doesn't understand dual CJS/ESM export import { isTypeScriptProject } from '@redwoodjs/cli-helpers' const JOBS_CONFIG_FILENAME = isTypeScriptProject() ? 'jobs.ts' : 'jobs.js' From d89ab8226d49ba7027e28c9926e967d7f1e51e95 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 7 Aug 2024 16:17:47 -0700 Subject: [PATCH 157/258] Update adapters for new format --- packages/jobs/src/adapters/BaseAdapter.ts | 7 ++++--- packages/jobs/src/adapters/PrismaAdapter.ts | 14 +++++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/jobs/src/adapters/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter.ts index 39538f87aec5..4cb58de24b00 100644 --- a/packages/jobs/src/adapters/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter.ts @@ -23,14 +23,15 @@ export interface SchedulePayload { // adapter will likely return more info, like the number of previous tries, so // that it can reschedule the job to run in the future. export interface BaseJob { - handler: string - args: any + name: string + path: string + args: unknown[] } export interface FindArgs { processName: string maxRuntime: number - queue: string | null + queues: string[] } export interface BaseAdapterOptions { diff --git a/packages/jobs/src/adapters/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter.ts index 47b5f7aa3106..43f964d641a1 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter.ts @@ -48,6 +48,7 @@ export const DEFAULTS = { interface PrismaJob extends BaseJob { id: number + handler: string attempts: number runAt: Date lockedAt: Date @@ -105,10 +106,10 @@ export class PrismaAdapter extends BaseAdapter { // TODO: there may be more optimized versions of the locking queries in // Postgres and MySQL, this.options.db._activeProvider returns the provider // name - async find({ + override async find({ processName, maxRuntime, - queue, + queues, }: FindArgs): Promise { const maxRuntimeExpire = new Date( new Date().getTime() + (maxRuntime || DEFAULTS.maxRuntime * 1000), @@ -148,10 +149,11 @@ export class PrismaAdapter extends BaseAdapter { ], } + // TODO(@rob): make this a WHERE..IN for multiple queues // for some reason prisma doesn't like it's own `query: { not: null }` // syntax, so only add the query condition if we're filtering by queue const whereWithQueue = Object.assign(where, { - AND: [...where.AND, { queue: queue || undefined }], + AND: [...where.AND, { queue: queues || undefined }], }) // Find the next job that should run now @@ -181,7 +183,9 @@ export class PrismaAdapter extends BaseAdapter { // Assuming the update worked, return the full details of the job if (count) { - return await this.accessor.findFirst({ where: { id: job.id } }) + const data = await this.accessor.findFirst({ where: { id: job.id } }) + const { name, path, args } = JSON.parse(data.handler) + return { ...data, name, path, args } } } @@ -194,7 +198,7 @@ export class PrismaAdapter extends BaseAdapter { // awaited, so do the await here to ensure they actually run. Otherwise the // user must always await `performLater()` or the job won't actually be // scheduled. - async success(job: PrismaJob) { + override async success(job: PrismaJob) { this.logger.debug(`Job ${job.id} success`) return await this.accessor.delete({ where: { id: job.id } }) } From 4acf7891c7bc4967773590cddd01e85104f55c61 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 7 Aug 2024 16:17:57 -0700 Subject: [PATCH 158/258] Remove type export in index --- packages/jobs/src/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/jobs/src/index.ts b/packages/jobs/src/index.ts index 58aabf01a25b..1db63e28895d 100644 --- a/packages/jobs/src/index.ts +++ b/packages/jobs/src/index.ts @@ -7,6 +7,4 @@ export { Worker } from './core/Worker' export { BaseAdapter } from './adapters/BaseAdapter' export { PrismaAdapter } from './adapters/PrismaAdapter' -export type { WorkerConfig } from './core/Worker' - // TODO(jgmw): We tend to avoid wanting to barrel export everything From 781e1ad09bf8cf88ace38156a4cdef991a601b15 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 7 Aug 2024 16:20:08 -0700 Subject: [PATCH 159/258] Update loads for new job design --- packages/jobs/src/loaders.ts | 39 +++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/jobs/src/loaders.ts b/packages/jobs/src/loaders.ts index e340aeca7220..d3f810c938c7 100644 --- a/packages/jobs/src/loaders.ts +++ b/packages/jobs/src/loaders.ts @@ -1,11 +1,11 @@ import path from 'node:path' import { pathToFileURL } from 'node:url' -import fg from 'fast-glob' - import { getPaths } from '@redwoodjs/project-config' +import type { JobManager } from './core/JobManager' import { JobsLibNotFoundError, JobNotFoundError } from './errors' +import type { Adapters, BasicLogger, Job } from './types' export function makeFilePath(path: string) { return pathToFileURL(path).href @@ -13,26 +13,41 @@ export function makeFilePath(path: string) { // Loads the named export from the app's jobs config in api/src/lib/jobs.{js,ts} // to configure the worker, defaults to `workerConfig` -export const loadJobsConfig = async () => { +export const loadJobsManager = (): JobManager< + Adapters, + string[], + BasicLogger +> => { const jobsConfigPath = getPaths().api.distJobsConfig if (jobsConfigPath) { - return require(jobsConfigPath) + return require(jobsConfigPath).jobs } else { throw new JobsLibNotFoundError() } } // Loads a job from the app's filesystem in api/src/jobs -export const loadJob = async (name: string) => { +export const loadJob = ({ + name: jobName, + path: jobPath, +}: { + name: string + path: string +}): Job => { const jobsPath = getPaths().api.distJobs - // Specifying {js,ts} extensions, so we don't accidentally try to load .json - // files or similar - const files = fg.sync(`**/${name}.{js,ts}`, { cwd: jobsPath }) - if (!files[0]) { - throw new JobNotFoundError(name) + let job + + try { + job = require(path.join(jobsPath, jobPath)) + } catch (e) { + throw new JobNotFoundError(jobName) } - const jobModule = require(path.join(jobsPath, files[0])) - return jobModule + + if (!job[jobName]) { + throw new JobNotFoundError(jobName) + } + + return job[jobName] } From b7faeaf75289893e0f384db95f41613e117b56b2 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 7 Aug 2024 16:20:16 -0700 Subject: [PATCH 160/258] Job worker script updates --- packages/jobs/src/bins/rw-jobs-worker.ts | 16 +++-- packages/jobs/src/bins/rw-jobs.ts | 74 +++++++++++++----------- 2 files changed, 50 insertions(+), 40 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index 1708d1dcddae..1bf24653c369 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -20,7 +20,7 @@ import { } from '../consts' import { Worker } from '../core/Worker' import { AdapterNotFoundError, WorkerConfigIndexNotFoundError } from '../errors' -import { loadJobsConfig } from '../loaders' +import { loadJobsManager } from '../loaders' import type { BasicLogger } from '../types' const parseArgs = (argv: string[]) => { @@ -52,8 +52,14 @@ const parseArgs = (argv: string[]) => { .help().argv } -const setProcessTitle = ({ id, queue }: { id: number; queue: string }) => { - process.title = `${PROCESS_TITLE_PREFIX}.${queue}.${id}` +const setProcessTitle = ({ + id, + queue, +}: { + id: number + queue: string | string[] +}) => { + process.title = `${PROCESS_TITLE_PREFIX}.${[queue].flat().join('-')}.${id}` } const setupSignals = ({ @@ -90,7 +96,7 @@ const main = async () => { let jobsConfig try { - jobsConfig = (await loadJobsConfig()).jobs + jobsConfig = loadJobsManager() } catch (e) { console.error(e) process.exit(1) @@ -128,7 +134,7 @@ const main = async () => { deleteFailedJobs: workerConfig.deleteFailedJobs ?? DEFAULT_DELETE_FAILED_JOBS, processName: process.title, - queue: workerConfig.queue ?? DEFAULT_WORK_QUEUE, + queues: [workerConfig.queue ?? DEFAULT_WORK_QUEUE].flat(), workoff, clear, }) diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index 5f83c11d3603..9de891425612 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -12,11 +12,13 @@ import { setTimeout } from 'node:timers' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' -import { loadEnvFiles } from '@redwoodjs/cli-helpers/dist/lib/loadEnvFiles.js' +// @ts-expect-error - doesn't understand dual CJS/ESM export +import * as cliHelperLoadEnv from '@redwoodjs/cli-helpers/loadEnvFiles' +const { loadEnvFiles } = cliHelperLoadEnv import { DEFAULT_LOGGER, PROCESS_TITLE_PREFIX } from '../consts' -import { loadJobsConfig } from '../loaders' -import type { BasicLogger } from '../types' +import { loadJobsManager } from '../loaders' +import type { Adapters, BasicLogger, WorkerConfig } from '../types' export type NumWorkersConfig = Array<[number, number]> @@ -70,15 +72,16 @@ const parseArgs = (argv: string[]) => { // The output would be: // // [ -// [0, 0], -// [0, 1], -// [1, 0], +// [0, 0], // first array, first worker +// [0, 1], // first array, second worker +// [1, 0], // second array, first worker // ] const buildNumWorkers = (config: any) => { - // @ts-ignore who cares - const workers = config.map((worker: any, index: number) => { + const workers: NumWorkersConfig = [] + + config.map((worker: any, index: number) => { for (let id = 0; id < worker.count; id++) { - return [index, id] + workers.push([index, id]) } }) @@ -130,8 +133,6 @@ const startWorkers = ({ // TODO add support for stopping with SIGTERM or SIGKILL? const stopWorkers = async ({ numWorkers, - // @ts-ignore who cares - workerConfig, signal = 'SIGINT', logger, }: { @@ -143,23 +144,19 @@ const stopWorkers = async ({ `Stopping ${numWorkers.length} worker(s) gracefully (${signal})...`, ) - for (const [index, id] of numWorkers) { - const queue = workerConfig[index].queue - const workerTitle = `${PROCESS_TITLE_PREFIX}${queue ? `.${queue}` : ''}.${id}` - const processId = await findProcessId(workerTitle) + const processIds = await findWorkerProcesses() - if (!processId) { - logger.warn(`No worker found with title ${workerTitle}`) - continue - } + if (processIds.length === 0) { + logger.warn(`No running workers found.`) + return + } - logger.info( - `Stopping worker ${workerTitle} with process id ${processId}...`, - ) + for (const processId of processIds) { + logger.info(`Stopping process id ${processId}...`) process.kill(processId, signal) // wait for the process to actually exit before going to next iteration - while (await findProcessId(workerTitle)) { + while ((await findWorkerProcesses(processId)).length) { await new Promise((resolve) => setTimeout(resolve, 250)) } } @@ -200,19 +197,19 @@ const signalSetup = ({ } // Find the process id of a worker by its title -const findProcessId = async (name: string): Promise => { +const findWorkerProcesses = async (id?: number): Promise => { return new Promise(function (resolve, reject) { const plat = process.platform const cmd = plat === 'win32' ? 'tasklist' : plat === 'darwin' - ? 'ps -ax | grep ' + name + ? 'ps -ax | grep ' + PROCESS_TITLE_PREFIX : plat === 'linux' ? 'ps -A' : '' - if (cmd === '' || name === '') { - resolve(null) + if (cmd === '') { + resolve([]) } exec(cmd, function (err, stdout) { if (err) { @@ -226,10 +223,20 @@ const findProcessId = async (name: string): Promise => { } return true }) + + // no job workers running if (matches.length === 0) { - resolve(null) + resolve([]) + } + + const pids = matches.map((line) => parseInt(line.split(' ')[0])) + + if (id) { + // will return the single job worker process ID if still running + resolve(pids.filter((pid) => pid === id)) } else { - resolve(parseInt(matches[0].split(' ')[0])) + // return all job worker process IDs + resolve(pids) } }) }) @@ -240,13 +247,13 @@ const main = async () => { let jobsConfig try { - jobsConfig = (await loadJobsConfig()).jobs + jobsConfig = loadJobsManager() } catch (e) { console.error(e) process.exit(1) } - const workerConfig = jobsConfig.workers + const workerConfig: WorkerConfig[] = jobsConfig.workers const numWorkers = buildNumWorkers(workerConfig) const logger = jobsConfig.logger ?? DEFAULT_LOGGER @@ -261,8 +268,7 @@ const main = async () => { }) return process.exit(0) case 'restart': - // @ts-ignore who cares - await stopWorkers({ numWorkers, workerConfig, signal: 'SIGINT', logger }) + await stopWorkers({ numWorkers, signal: 'SIGINT', logger }) startWorkers({ numWorkers, detach: true, @@ -289,8 +295,6 @@ const main = async () => { case 'stop': return await stopWorkers({ numWorkers, - // @ts-ignore who cares - workerConfig, signal: 'SIGINT', logger, }) From c98176967c95b852a211fc6227f98f54787381ce Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 7 Aug 2024 16:20:26 -0700 Subject: [PATCH 161/258] Worker and Executor updates for new job structure --- packages/jobs/src/core/Executor.ts | 16 ++---- packages/jobs/src/core/Worker.ts | 82 +++++++++--------------------- 2 files changed, 28 insertions(+), 70 deletions(-) diff --git a/packages/jobs/src/core/Executor.ts b/packages/jobs/src/core/Executor.ts index 6cbace635187..e2d8e200c87f 100644 --- a/packages/jobs/src/core/Executor.ts +++ b/packages/jobs/src/core/Executor.ts @@ -4,11 +4,7 @@ import console from 'node:console' import type { BaseAdapter } from '../adapters/BaseAdapter' import { DEFAULT_MAX_ATTEMPTS, DEFAULT_DELETE_FAILED_JOBS } from '../consts' -import { - AdapterRequiredError, - JobRequiredError, - JobExportNotFoundError, -} from '../errors' +import { AdapterRequiredError, JobRequiredError } from '../errors' import { loadJob } from '../loaders' import type { BasicLogger } from '../types' @@ -62,18 +58,14 @@ export class Executor { async perform() { this.logger.info(this.job, `Started job ${this.job.id}`) - const details = JSON.parse(this.job.handler) // TODO break these lines down into individual try/catch blocks? try { - const jobModule = await loadJob(details.handler) - await new jobModule[details.handler]().perform(...details.args) + const job = loadJob({ name: this.job.name, path: this.job.path }) + await job.perform(...this.job.args) return this.adapter.success(this.job) } catch (e: any) { - let error = e - if (e.message.match(/is not a constructor/)) { - error = new JobExportNotFoundError(details.handler) - } + const error = e this.logger.error(error.stack) return this.adapter.failure(this.job, error, { maxAttempts: this.maxAttempts, diff --git a/packages/jobs/src/core/Worker.ts b/packages/jobs/src/core/Worker.ts index 9720bee0fc37..8bb5d48a3e5e 100644 --- a/packages/jobs/src/core/Worker.ts +++ b/packages/jobs/src/core/Worker.ts @@ -1,75 +1,33 @@ // Used by the job runner to find the next job to run and invoke the Executor -import console from 'node:console' -import process from 'node:process' import { setTimeout } from 'node:timers' import type { BaseAdapter } from '../adapters/BaseAdapter' -import { - DEFAULT_MAX_ATTEMPTS, - DEFAULT_MAX_RUNTIME, - DEFAULT_SLEEP_DELAY, - DEFAULT_DELETE_FAILED_JOBS, -} from '../consts' import { AdapterRequiredError } from '../errors' import type { BasicLogger } from '../types' import { Executor } from './Executor' -// The options set in api/src/lib/jobs.ts -export interface WorkerConfig { - maxAttempts?: number - maxRuntime?: number - deleteFailedJobs?: boolean - sleepDelay?: number -} - -// Additional options that the rw-jobs-worker process will set when -// instantiatng the Worker class -interface Options { +interface WorkerConfig { adapter: BaseAdapter - logger?: BasicLogger - clear?: boolean - processName?: string - queue?: string | null - forever?: boolean - workoff?: boolean -} - -// The default options to be used if any of the above are not set -interface DefaultOptions { logger: BasicLogger + clear: boolean + processName: string + queues: string[] maxAttempts: number maxRuntime: number deleteFailedJobs: boolean sleepDelay: number - clear: boolean - processName: string - queue: string | null - forever: boolean workoff: boolean } -export const DEFAULTS: DefaultOptions = { - logger: console, - processName: process.title, - queue: null, - clear: false, - maxAttempts: DEFAULT_MAX_ATTEMPTS, - maxRuntime: DEFAULT_MAX_RUNTIME, // 4 hours in seconds - sleepDelay: DEFAULT_SLEEP_DELAY, // 5 seconds - deleteFailedJobs: DEFAULT_DELETE_FAILED_JOBS, - forever: true, - workoff: false, -} - export class Worker { - options: WorkerConfig & Options & DefaultOptions + options: WorkerConfig adapter: BaseAdapter logger: BasicLogger clear: boolean processName: string - queue: string | null + queues: string[] maxAttempts: number maxRuntime: number deleteFailedJobs: boolean @@ -78,8 +36,8 @@ export class Worker { forever: boolean workoff: boolean - constructor(options: WorkerConfig & Options) { - this.options = { ...DEFAULTS, ...options } + constructor(options: WorkerConfig) { + this.options = options if (!options?.adapter) { throw new AdapterRequiredError() @@ -95,7 +53,7 @@ export class Worker { this.processName = this.options.processName // if not given a queue name then will work on jobs in any queue - this.queue = this.options.queue + this.queues = this.options.queues // the maximum number of times to retry a failed job this.maxAttempts = this.options.maxAttempts @@ -112,11 +70,11 @@ export class Worker { // maximum wait time. Do an `undefined` check here so we can set to 0 this.sleepDelay = this.options.sleepDelay * 1000 - // Set to `false` if the work loop should only run one time, regardless - // of how many outstanding jobs there are to be worked on. The worker - // process will set this to `false` as soon as the user hits ctrl-c so - // any current job will complete before exiting. - this.forever = this.options.forever + // Set to `false` and the work loop will quit when the current job is done + // running (regardless of how many outstanding jobs there are to be worked + // on). The worker process will set this to `false` as soon as the user hits + // ctrl-c so any current job will complete before exiting. + this.forever = true // Set to `true` if the work loop should run through all *available* jobs // and then quit. Serves a slightly different purpose than `forever` which @@ -139,6 +97,14 @@ export class Worker { } } + get queueNames() { + if (this.queues.length === 1 && this.queues[0] === '*') { + return 'all (*)' + } else { + return this.queues.join(', ') + } + } + async #clearQueue() { return await this.adapter.clear() } @@ -148,13 +114,13 @@ export class Worker { this.lastCheckTime = new Date() this.logger.debug( - `[${this.processName}] Checking for jobs in ${this.queue ? `${this.queue} queue` : 'all queues'}...`, + `[${this.processName}] Checking for jobs in ${this.queueNames} queues...`, ) const job = await this.adapter.find({ processName: this.processName, maxRuntime: this.maxRuntime, - queue: this.queue, + queues: this.queues, }) if (job) { From 4a385c2c07fd6b4106eecf181afb16bc1e57aa93 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 8 Aug 2024 15:41:25 -0700 Subject: [PATCH 162/258] Updates BaseAdapter and PrismaAdapter to have similar method signatures (destructured object), removes need for maxAttempts option, adds separate error() and failure() functions, adds `deleteSuccessfulJobs` option --- packages/jobs/src/adapters/BaseAdapter.ts | 48 +++++-- packages/jobs/src/adapters/PrismaAdapter.ts | 126 +++++++++-------- .../adapters/__tests__/BaseAdapter.test.ts | 9 +- .../adapters/__tests__/PrismaAdapter.test.ts | 130 +++++++++++------- packages/jobs/src/consts.ts | 3 + 5 files changed, 199 insertions(+), 117 deletions(-) diff --git a/packages/jobs/src/adapters/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter.ts index 4cb58de24b00..140958f68b9f 100644 --- a/packages/jobs/src/adapters/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter.ts @@ -26,6 +26,7 @@ export interface BaseJob { name: string path: string args: unknown[] + attempts: number } export interface FindArgs { @@ -38,8 +39,11 @@ export interface BaseAdapterOptions { logger?: BasicLogger } +export interface SuccessOptions { + deleteSuccessfulJobs?: boolean +} + export interface FailureOptions { - maxAttempts?: number deleteFailedJobs?: boolean } @@ -59,16 +63,42 @@ export abstract class BaseAdapter< // want to do something with the result depending on the adapter type, so make // it `any` to allow for the subclass to return whatever it wants. - abstract schedule(payload: SchedulePayload): any + abstract schedule({ + job, + args, + runAt, + queue, + priority, + }: SchedulePayload): void + + // Find a single job that's elegible to run with the given args + abstract find({ + processName, + maxRuntime, + queues, + }: FindArgs): BaseJob | null | Promise - abstract find( - args: FindArgs, - ): BaseJob | null | undefined | Promise + // Job succeeded + abstract success({ + job, + deleteJob, + }: { + job: BaseJob + deleteJob: boolean + }): void - // TODO accept an optional `queue` arg to clear only jobs in that queue - abstract clear(): any + // Job errored + abstract error({ job, error }: { job: BaseJob; error: Error }): void - abstract success(job: BaseJob): any + // Job errored more than maxAttempts, now a permanent failure + abstract failure({ + job, + deleteJob, + }: { + job: BaseJob + deleteJob: boolean + }): void - abstract failure(job: BaseJob, error: Error, options: FailureOptions): any + // Remove all jobs from storage + abstract clear(): void } diff --git a/packages/jobs/src/adapters/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter.ts index 43f964d641a1..d3999613e579 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter.ts @@ -1,5 +1,5 @@ // Implements a job adapter using Prisma ORM. Assumes a table exists with the -// following schema (the table name and primary key name can be customized): +// following schema (the table name can be customized): // // model BackgroundJob { // id Int @id @default(autoincrement()) @@ -15,19 +15,11 @@ // createdAt DateTime @default(now()) // updatedAt DateTime @updatedAt // } -// -// Initialize this adapter passing an `accessor` which is the property on an -// instance of PrismaClient that points to the table thats stores the jobs. In -// the above schema, PrismaClient will create a `backgroundJob` property on -// Redwood's `db` instance: -// -// import { db } from 'src/lib/db' -// const adapter = new PrismaAdapter({ accessor: db.backgroundJob }) -// RedwoodJob.config({ adapter }) import type { PrismaClient } from '@prisma/client' import { camelCase } from 'change-case' +import { DEFAULT_MAX_RUNTIME, DEFAULT_MODEL_NAME } from '../consts' import { ModelNameError } from '../errors' import type { @@ -35,21 +27,12 @@ import type { BaseAdapterOptions, FindArgs, SchedulePayload, - FailureOptions, } from './BaseAdapter' import { BaseAdapter } from './BaseAdapter' -export const DEFAULTS = { - model: 'BackgroundJob', - maxAttempts: 24, - maxRuntime: 14_400, - deleteFailedJobs: false, -} - interface PrismaJob extends BaseJob { id: number handler: string - attempts: number runAt: Date lockedAt: Date lockedBy: string @@ -60,10 +43,24 @@ interface PrismaJob extends BaseJob { } interface PrismaAdapterOptions extends BaseAdapterOptions { + /** + * An instance of PrismaClient which will be used to talk to the database + */ db: PrismaClient + /** + * The name of the model in the Prisma schema that represents the job table. + * @default 'BackgroundJob' + */ model?: string } +interface SuccessData { + lockedAt: null + lockedBy: null + lastError: null + runAt: null +} + interface FailureData { lockedAt: null lockedBy: null @@ -81,16 +78,16 @@ export class PrismaAdapter extends BaseAdapter { constructor(options: PrismaAdapterOptions) { super(options) - // instance of PrismaClient this.db = options.db // name of the model as defined in schema.prisma - this.model = options.model || DEFAULTS.model + this.model = options.model || DEFAULT_MODEL_NAME // the function to call on `db` to make queries: `db.backgroundJob` this.accessor = this.db[camelCase(this.model)] // the database provider type: 'sqlite' | 'postgresql' | 'mysql' + // not used currently, but may be useful in the future for optimizations this.provider = options.db._activeProvider // validate that everything we need is available @@ -103,16 +100,16 @@ export class PrismaAdapter extends BaseAdapter { // The act of locking a job is dependant on the DB server, so we'll run some // raw SQL to do it in each case—Prisma doesn't provide enough flexibility // in their generated code to do this in a DB-agnostic way. + // // TODO: there may be more optimized versions of the locking queries in - // Postgres and MySQL, this.options.db._activeProvider returns the provider - // name + // Postgres and MySQL override async find({ processName, maxRuntime, queues, }: FindArgs): Promise { const maxRuntimeExpire = new Date( - new Date().getTime() + (maxRuntime || DEFAULTS.maxRuntime * 1000), + new Date().getTime() + (maxRuntime || DEFAULT_MAX_RUNTIME * 1000), ) // This query is gnarly but not so bad once you know what it's doing. For a @@ -149,14 +146,16 @@ export class PrismaAdapter extends BaseAdapter { ], } - // TODO(@rob): make this a WHERE..IN for multiple queues - // for some reason prisma doesn't like it's own `query: { not: null }` - // syntax, so only add the query condition if we're filtering by queue - const whereWithQueue = Object.assign(where, { - AND: [...where.AND, { queue: queues || undefined }], - }) + // If queues is ['*'] then skip, otherwise add a WHERE...IN for the array of + // queue names + const whereWithQueue = where + if (queues.length > 1 || queues[0] !== '*') { + Object.assign(whereWithQueue, { + AND: [...where.AND, { queue: { in: queues } }], + }) + } - // Find the next job that should run now + // Actually query the DB const job = await this.accessor.findFirst({ select: { id: true, attempts: true }, where: whereWithQueue, @@ -172,6 +171,7 @@ export class PrismaAdapter extends BaseAdapter { AND: [...whereWithQueue.AND, { id: job.id }], }) + // Update and increment the attempts count const { count } = await this.accessor.updateMany({ where: whereWithQueueAndId, data: { @@ -198,24 +198,27 @@ export class PrismaAdapter extends BaseAdapter { // awaited, so do the await here to ensure they actually run. Otherwise the // user must always await `performLater()` or the job won't actually be // scheduled. - override async success(job: PrismaJob) { + override success({ job, deleteJob }: { job: PrismaJob; deleteJob: boolean }) { this.logger.debug(`Job ${job.id} success`) - return await this.accessor.delete({ where: { id: job.id } }) + + if (deleteJob) { + this.accessor.delete({ where: { id: job.id } }) + } else { + this.accessor.update({ + where: { id: job.id }, + data: { + lockedAt: null, + lockedBy: null, + lastError: null, + runAt: null, + }, + }) + } } - async failure(job: PrismaJob, error: Error, options?: FailureOptions) { + override error({ job, error }: { job: PrismaJob; error: Error }) { this.logger.debug(`Job ${job.id} failure`) - const shouldDeleteFailed = - options?.deleteFailedJobs ?? DEFAULTS.deleteFailedJobs - - if ( - job.attempts >= (options?.maxAttempts || DEFAULTS.maxAttempts) && - shouldDeleteFailed - ) { - return await this.accessor.delete({ where: { id: job.id } }) - } - const data: FailureData = { lockedAt: null, lockedBy: null, @@ -223,24 +226,31 @@ export class PrismaAdapter extends BaseAdapter { runAt: null, } - if (job.attempts >= (options?.maxAttempts || DEFAULTS.maxAttempts)) { - data.failedAt = new Date() - } else { - data.runAt = new Date( - new Date().getTime() + this.backoffMilliseconds(job.attempts), - ) - } + data.runAt = new Date( + new Date().getTime() + this.backoffMilliseconds(job.attempts), + ) - return await this.accessor.update({ + this.accessor.update({ where: { id: job.id }, data, }) } - // Schedules a job by creating a new record in a `BackgroundJob` table - // (or whatever the accessor is configured to point to). - async schedule({ job, args, runAt, queue, priority }: SchedulePayload) { - return await this.accessor.create({ + // Job has had too many attempts, it is not permanently failed. + override failure({ job, deleteJob }: { job: PrismaJob; deleteJob: boolean }) { + if (deleteJob) { + this.accessor.delete({ where: { id: job.id } }) + } else { + this.accessor.update({ + where: { id: job.id }, + data: { failedAt: new Date() }, + }) + } + } + + // Schedules a job by creating a new record in the background job table + override schedule({ job, args, runAt, queue, priority }: SchedulePayload) { + this.accessor.create({ data: { handler: JSON.stringify({ job, args }), runAt, @@ -250,8 +260,8 @@ export class PrismaAdapter extends BaseAdapter { }) } - async clear() { - return await this.accessor.deleteMany() + override clear() { + this.accessor.deleteMany() } backoffMilliseconds(attempts: number) { diff --git a/packages/jobs/src/adapters/__tests__/BaseAdapter.test.ts b/packages/jobs/src/adapters/__tests__/BaseAdapter.test.ts index 28518dc36f20..46f5bccfda24 100644 --- a/packages/jobs/src/adapters/__tests__/BaseAdapter.test.ts +++ b/packages/jobs/src/adapters/__tests__/BaseAdapter.test.ts @@ -17,12 +17,13 @@ interface TestAdapterOptions extends BaseAdapterOptions { class TestAdapter extends BaseAdapter { schedule() {} - clear() {} - success() {} - failure() {} find() { - return undefined + return null } + success() {} + error() {} + failure() {} + clear() {} } describe('constructor', () => { diff --git a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts index ff21f18e2d01..4cfbd26e5f88 100644 --- a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts +++ b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts @@ -3,8 +3,9 @@ import { describe, expect, vi, it, beforeEach, afterEach } from 'vitest' import type CliHelpers from '@redwoodjs/cli-helpers' -import * as errors from '../../core/errors' -import { PrismaAdapter, DEFAULTS } from '../PrismaAdapter' +import { DEFAULT_MODEL_NAME } from '../../consts' +import * as errors from '../../errors' +import { PrismaAdapter } from '../PrismaAdapter' vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) @@ -55,7 +56,7 @@ describe('constructor', () => { it('defaults this.model name', () => { const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) - expect(adapter.model).toEqual(DEFAULTS.model) + expect(adapter.model).toEqual(DEFAULT_MODEL_NAME) }) it('can manually set this.model', () => { @@ -130,28 +131,48 @@ describe('find()', () => { const job = await adapter.find({ processName: 'test', maxRuntime: 1000, - queue: 'foobar', + queues: ['foobar'], }) expect(job).toBeNull() }) it('returns a job if found', async () => { - const mockJob = { id: 1 } + const mockJob = { + id: 1, + handler: JSON.stringify({ + name: 'TestJob', + path: 'TestJob/TestJob', + args: [], + }), + } vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) vi.spyOn(mockDb.backgroundJob, 'updateMany').mockReturnValue({ count: 1 }) const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) const job = await adapter.find({ processName: 'test', maxRuntime: 1000, - queue: 'default', + queues: ['default'], }) - expect(job).toEqual(mockJob) + expect(job).toEqual({ + ...mockJob, + name: 'TestJob', + path: 'TestJob/TestJob', + args: [], + }) }) it('increments the `attempts` count on the found job', async () => { - const mockJob = { id: 1, attempts: 0 } + const mockJob = { + id: 1, + handler: JSON.stringify({ + name: 'TestJob', + path: 'TestJob/TestJob', + args: [], + }), + attempts: 0, + } vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) const updateSpy = vi .spyOn(mockDb.backgroundJob, 'updateMany') @@ -160,7 +181,7 @@ describe('find()', () => { await adapter.find({ processName: 'test', maxRuntime: 1000, - queue: 'default', + queues: ['default'], }) expect(updateSpy).toHaveBeenCalledWith( @@ -171,7 +192,15 @@ describe('find()', () => { }) it('locks the job for the current process', async () => { - const mockJob = { id: 1, attempts: 0 } + const mockJob = { + id: 1, + attempts: 0, + handler: JSON.stringify({ + name: 'TestJob', + path: 'TestJob/TestJob', + args: [], + }), + } vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) const updateSpy = vi .spyOn(mockDb.backgroundJob, 'updateMany') @@ -180,7 +209,7 @@ describe('find()', () => { await adapter.find({ processName: 'test-process', maxRuntime: 1000, - queue: 'default', + queues: ['default'], }) expect(updateSpy).toHaveBeenCalledWith( @@ -191,7 +220,15 @@ describe('find()', () => { }) it('locks the job with a current timestamp', async () => { - const mockJob = { id: 1, attempts: 0 } + const mockJob = { + id: 1, + attempts: 0, + handler: JSON.stringify({ + name: 'TestJob', + path: 'TestJob/TestJob', + args: [], + }), + } vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(mockJob) const updateSpy = vi .spyOn(mockDb.backgroundJob, 'updateMany') @@ -200,7 +237,7 @@ describe('find()', () => { await adapter.find({ processName: 'test-process', maxRuntime: 1000, - queue: 'default', + queues: ['default'], }) expect(updateSpy).toHaveBeenCalledWith( @@ -214,7 +251,6 @@ describe('find()', () => { const mockPrismaJob = { id: 1, handler: '', - args: undefined, attempts: 10, runAt: new Date(), lockedAt: new Date(), @@ -223,26 +259,48 @@ const mockPrismaJob = { failedAt: null, createdAt: new Date(), updatedAt: new Date(), + name: 'TestJob', + path: 'TestJob/TestJob', + args: [], } describe('success()', () => { - it('deletes the job from the DB', async () => { + it('deletes the job from the DB if option set', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'delete') const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger, }) - await adapter.success(mockPrismaJob) + await adapter.success({ job: mockPrismaJob, deleteJob: true }) expect(spy).toHaveBeenCalledWith({ where: { id: 1 } }) }) + + it('updates the job if option not set', async () => { + const spy = vi.spyOn(mockDb.backgroundJob, 'update') + const adapter = new PrismaAdapter({ + db: mockDb, + logger: mockLogger, + }) + await adapter.success({ job: mockPrismaJob, deleteJob: false }) + + expect(spy).toHaveBeenCalledWith({ + where: { id: mockPrismaJob.id }, + data: { + lockedAt: null, + lockedBy: null, + lastError: null, + runAt: null, + }, + }) + }) }) -describe('failure()', () => { +describe('error()', () => { it('updates the job by id', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) - await adapter.failure(mockPrismaJob, new Error('test error')) + await adapter.error({ job: mockPrismaJob, error: new Error('test error') }) expect(spy).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 1 } }), @@ -252,7 +310,7 @@ describe('failure()', () => { it('clears the lock fields', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) - await adapter.failure(mockPrismaJob, new Error('test error')) + await adapter.error({ job: mockPrismaJob, error: new Error('test error') }) expect(spy).toHaveBeenCalledWith( expect.objectContaining({ @@ -264,7 +322,7 @@ describe('failure()', () => { it('reschedules the job at a designated backoff time', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) - await adapter.failure(mockPrismaJob, new Error('test error')) + await adapter.error({ job: mockPrismaJob, error: new Error('test error') }) expect(spy).toHaveBeenCalledWith( expect.objectContaining({ @@ -278,7 +336,7 @@ describe('failure()', () => { it('records the error', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) - await adapter.failure(mockPrismaJob, new Error('test error')) + await adapter.error({ job: mockPrismaJob, error: new Error('test error') }) expect(spy).toHaveBeenCalledWith( expect.objectContaining({ @@ -288,30 +346,13 @@ describe('failure()', () => { }), ) }) +}) - it('nullifies runtAt if max attempts reached', async () => { - const spy = vi.spyOn(mockDb.backgroundJob, 'update') - const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) - await adapter.failure(mockPrismaJob, new Error('test error'), { - maxAttempts: 10, - }) - - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - runAt: null, - }), - }), - ) - }) - +describe('failure()', () => { it('marks the job as failed if max attempts reached', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'update') const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) - await adapter.failure(mockPrismaJob, new Error('test error'), { - maxAttempts: 10, - deleteFailedJobs: false, - }) + await adapter.failure({ job: mockPrismaJob, deleteJob: false }) expect(spy).toHaveBeenCalledWith( expect.objectContaining({ @@ -322,13 +363,10 @@ describe('failure()', () => { ) }) - it('deletes the job if max attempts reached and deleteFailedJobs set to true', async () => { + it('deletes the job if option is set', async () => { const spy = vi.spyOn(mockDb.backgroundJob, 'delete') const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) - await adapter.failure(mockPrismaJob, new Error('test error'), { - maxAttempts: 10, - deleteFailedJobs: true, - }) + await adapter.failure({ job: mockPrismaJob, deleteJob: true }) expect(spy).toHaveBeenCalledWith({ where: { id: 1 } }) }) diff --git a/packages/jobs/src/consts.ts b/packages/jobs/src/consts.ts index 15ceade43de0..d9f33b67301d 100644 --- a/packages/jobs/src/consts.ts +++ b/packages/jobs/src/consts.ts @@ -5,6 +5,8 @@ export const DEFAULT_MAX_ATTEMPTS = 24 export const DEFAULT_MAX_RUNTIME = 14_400 /** 5 seconds */ export const DEFAULT_SLEEP_DELAY = 5 + +export const DEFAULT_DELETE_SUCCESSFUL_JOBS = true export const DEFAULT_DELETE_FAILED_JOBS = false export const DEFAULT_LOGGER = console export const DEFAULT_QUEUE = 'default' @@ -13,6 +15,7 @@ export const DEFAULT_PRIORITY = 50 export const DEFAULT_WAIT = 0 export const DEFAULT_WAIT_UNTIL = null export const PROCESS_TITLE_PREFIX = 'rw-jobs-worker' +export const DEFAULT_MODEL_NAME = 'BackgroundJob' /** * The name of the exported variable from the jobs config file that contains From 57ace063e3d6d93aa31f549e02d3cdce9de1c169 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 9 Aug 2024 08:48:11 -0700 Subject: [PATCH 163/258] Updates Executor to make the call between adapter.error() and adapter.failure() based on number of attempts --- packages/jobs/src/core/Executor.ts | 40 +++++-- .../jobs/src/core/__tests__/Executor.test.js | 109 ++++++++++++++++-- 2 files changed, 130 insertions(+), 19 deletions(-) diff --git a/packages/jobs/src/core/Executor.ts b/packages/jobs/src/core/Executor.ts index e2d8e200c87f..b0834caf8f84 100644 --- a/packages/jobs/src/core/Executor.ts +++ b/packages/jobs/src/core/Executor.ts @@ -3,7 +3,11 @@ import console from 'node:console' import type { BaseAdapter } from '../adapters/BaseAdapter' -import { DEFAULT_MAX_ATTEMPTS, DEFAULT_DELETE_FAILED_JOBS } from '../consts' +import { + DEFAULT_MAX_ATTEMPTS, + DEFAULT_DELETE_FAILED_JOBS, + DEFAULT_DELETE_SUCCESSFUL_JOBS, +} from '../consts' import { AdapterRequiredError, JobRequiredError } from '../errors' import { loadJob } from '../loaders' import type { BasicLogger } from '../types' @@ -14,12 +18,14 @@ interface Options { logger?: BasicLogger maxAttempts?: number deleteFailedJobs?: boolean + deleteSuccessfulJobs?: boolean } interface DefaultOptions { logger: BasicLogger maxAttempts: number deleteFailedJobs: boolean + deleteSuccessfulJobs: boolean } type CompleteOptions = Options & DefaultOptions @@ -28,6 +34,7 @@ export const DEFAULTS: DefaultOptions = { logger: console, maxAttempts: DEFAULT_MAX_ATTEMPTS, deleteFailedJobs: DEFAULT_DELETE_FAILED_JOBS, + deleteSuccessfulJobs: DEFAULT_DELETE_SUCCESSFUL_JOBS, } export class Executor { @@ -37,6 +44,7 @@ export class Executor { job: any | null maxAttempts: number deleteFailedJobs: boolean + deleteSuccessfulJobs: boolean constructor(options: Options) { this.options = { ...DEFAULTS, ...options } @@ -54,6 +62,7 @@ export class Executor { this.job = this.options.job this.maxAttempts = this.options.maxAttempts this.deleteFailedJobs = this.options.deleteFailedJobs + this.deleteSuccessfulJobs = this.options.deleteSuccessfulJobs } async perform() { @@ -63,14 +72,31 @@ export class Executor { try { const job = loadJob({ name: this.job.name, path: this.job.path }) await job.perform(...this.job.args) - return this.adapter.success(this.job) - } catch (e: any) { - const error = e + + // TODO(@rob): Ask Josh about why this would "have no effect"? + await this.adapter.success({ + job: this.job, + deleteJob: DEFAULT_DELETE_SUCCESSFUL_JOBS, + }) + } catch (error: any) { + this.logger.error(`Error in job ${this.job.id}: ${error.message}`) this.logger.error(error.stack) - return this.adapter.failure(this.job, error, { - maxAttempts: this.maxAttempts, - deleteFailedJobs: this.deleteFailedJobs, + + await this.adapter.error({ + job: this.job, + error, }) + + if (this.job.attempts >= this.maxAttempts) { + this.logger.warn( + this.job, + `Failed job ${this.job.id}: reached max attempts`, + ) + await this.adapter.failure({ + job: this.job, + deleteJob: this.deleteFailedJobs, + }) + } } } } diff --git a/packages/jobs/src/core/__tests__/Executor.test.js b/packages/jobs/src/core/__tests__/Executor.test.js index 459335543512..539ad070cdec 100644 --- a/packages/jobs/src/core/__tests__/Executor.test.js +++ b/packages/jobs/src/core/__tests__/Executor.test.js @@ -1,8 +1,9 @@ import console from 'node:console' -import { describe, expect, vi, it } from 'vitest' +import { beforeEach, describe, expect, vi, it } from 'vitest' -import * as errors from '../../core/errors' +import * as errors from '../../errors' +import { loadJob } from '../../loaders' import { Executor } from '../Executor' // so that registerApiSideBabelHook() doesn't freak out about redwood.toml @@ -17,6 +18,25 @@ vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { } }) +const mocks = vi.hoisted(() => { + return { + loadJob: vi.fn(), + } +}) + +vi.mock('../../loaders', () => { + return { + loadJob: mocks.loadJob, + } +}) + +const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +} + describe('constructor', () => { it('saves options', () => { const options = { adapter: 'adapter', job: 'job' } @@ -67,26 +87,91 @@ describe('constructor', () => { }) describe('perform', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + // TODO once these dynamic imports are converted into loadJob in shared, just mock out the result of loadJob - it.skip('invokes the `perform` method on the job class', async () => { + it('invokes the `perform` method on the job class', async () => { + const mockAdapter = { success: vi.fn() } const options = { - adapter: 'adapter', - job: { handler: JSON.stringify({ handler: 'Foo', args: ['bar'] }) }, + adapter: mockAdapter, + logger: mockLogger, + job: { id: 1, name: 'TestJob', path: 'TestJob/TestJob', args: ['foo'] }, } const executor = new Executor(options) const job = { id: 1 } - const mockJob = vi.fn(() => { - return { perform: vi.fn() } - }) - vi.mock(`../Foo`, () => ({ Foo: mockJob }), { virtual: true }) + // mock the job + const mockJob = { perform: vi.fn() } + // spy on the perform method + const performSpy = vi.spyOn(mockJob, 'perform') + // mock the `loadJob` loader to return the job mock + mocks.loadJob.mockImplementation(() => mockJob) await executor.perform(job) - expect(mockJob).toHaveBeenCalledWith('bar') + expect(performSpy).toHaveBeenCalledWith('foo') }) - it.skip('invokes the `success` method on the adapter when job successful', async () => {}) + it('invokes the `success` method on the adapter when job successful', async () => { + const mockAdapter = { success: vi.fn() } + const options = { + adapter: mockAdapter, + logger: mockLogger, + job: { id: 1, name: 'TestJob', path: 'TestJob/TestJob', args: ['foo'] }, + } + const executor = new Executor(options) + const job = { id: 1 } + + // mock the job + const mockJob = { perform: vi.fn() } + // spy on the success function of the adapter + const adapterSpy = vi.spyOn(mockAdapter, 'success') + // mock the `loadJob` loader to return the job mock + mocks.loadJob.mockImplementation(() => mockJob) - it.skip('invokes the `failure` method on the adapter when job fails', async () => {}) + await executor.perform(job) + + expect(adapterSpy).toHaveBeenCalledWith({ + job: options.job, + deleteJob: true, + }) + }) + + it('invokes the `failure` method on the adapter when job fails', async () => { + const mockAdapter = { error: vi.fn() } + const options = { + adapter: mockAdapter, + logger: mockLogger, + job: { + id: 1, + name: 'TestJob', + path: 'TestJob/TestJob', + args: ['foo'], + attempts: 0, + }, + } + const executor = new Executor(options) + const job = { id: 1 } + + const error = new Error() + // mock the job + const mockJob = { + perform: vi.fn(() => { + throw error + }), + } + // spy on the success function of the adapter + const adapterSpy = vi.spyOn(mockAdapter, 'error') + // mock the `loadJob` loader to return the job mock + mocks.loadJob.mockImplementation(() => mockJob) + + await executor.perform(job) + + expect(adapterSpy).toHaveBeenCalledWith({ + job: options.job, + error, + }) + }) }) From 38ac0f47246a8efd09a3495dcff96d0510fa2ac9 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 9 Aug 2024 10:49:50 -0700 Subject: [PATCH 164/258] Updates Worker with new options, updates tests --- packages/jobs/src/core/Worker.ts | 100 ++++++-- .../jobs/src/core/__tests__/Worker.test.js | 225 +++++++++--------- 2 files changed, 184 insertions(+), 141 deletions(-) diff --git a/packages/jobs/src/core/Worker.ts b/packages/jobs/src/core/Worker.ts index 8bb5d48a3e5e..dec561aca17d 100644 --- a/packages/jobs/src/core/Worker.ts +++ b/packages/jobs/src/core/Worker.ts @@ -3,46 +3,89 @@ import { setTimeout } from 'node:timers' import type { BaseAdapter } from '../adapters/BaseAdapter' -import { AdapterRequiredError } from '../errors' +import { + DEFAULT_DELETE_FAILED_JOBS, + DEFAULT_DELETE_SUCCESSFUL_JOBS, + DEFAULT_LOGGER, + DEFAULT_MAX_ATTEMPTS, + DEFAULT_MAX_RUNTIME, + DEFAULT_SLEEP_DELAY, +} from '../consts' +import { AdapterRequiredError, QueuesRequiredError } from '../errors' import type { BasicLogger } from '../types' import { Executor } from './Executor' -interface WorkerConfig { +interface WorkerOptions { adapter: BaseAdapter - logger: BasicLogger - clear: boolean + logger?: BasicLogger + clear?: boolean processName: string queues: string[] - maxAttempts: number - maxRuntime: number - deleteFailedJobs: boolean - sleepDelay: number - workoff: boolean + maxAttempts?: number + maxRuntime?: number + deleteSuccessfulJobs?: boolean + deleteFailedJobs?: boolean + sleepDelay?: number + workoff?: boolean + // Makes testing much easier: we can set to false to NOT run in an infinite + // loop by default during tests + forever?: boolean +} + +interface DefaultOptions { + logger: WorkerOptions['logger'] + clear: WorkerOptions['clear'] + maxAttempts: WorkerOptions['maxAttempts'] + maxRuntime: WorkerOptions['maxRuntime'] + deleteSuccessfulJobs: WorkerOptions['deleteSuccessfulJobs'] + deleteFailedJobs: WorkerOptions['deleteFailedJobs'] + sleepDelay: WorkerOptions['sleepDelay'] + workoff: WorkerOptions['workoff'] + forever: WorkerOptions['forever'] +} + +type CompleteOptions = WorkerOptions & DefaultOptions + +const DEFAULT_OPTIONS: DefaultOptions = { + logger: DEFAULT_LOGGER, + clear: false, + maxAttempts: DEFAULT_MAX_ATTEMPTS, + maxRuntime: DEFAULT_MAX_RUNTIME, + deleteSuccessfulJobs: DEFAULT_DELETE_SUCCESSFUL_JOBS, + deleteFailedJobs: DEFAULT_DELETE_FAILED_JOBS, + sleepDelay: DEFAULT_SLEEP_DELAY, + workoff: false, + forever: true, } export class Worker { - options: WorkerConfig - adapter: BaseAdapter - logger: BasicLogger - clear: boolean - processName: string - queues: string[] - maxAttempts: number - maxRuntime: number - deleteFailedJobs: boolean - sleepDelay: number + options: CompleteOptions + adapter: CompleteOptions['adapter'] + logger: CompleteOptions['logger'] + clear: CompleteOptions['clear'] + processName: CompleteOptions['processName'] + queues: CompleteOptions['queues'] + maxAttempts: CompleteOptions['maxAttempts'] + maxRuntime: CompleteOptions['maxRuntime'] + deleteSuccessfulJobs: CompleteOptions['deleteSuccessfulJobs'] + deleteFailedJobs: CompleteOptions['deleteFailedJobs'] + sleepDelay: CompleteOptions['sleepDelay'] + forever: CompleteOptions['forever'] + workoff: CompleteOptions['workoff'] lastCheckTime: Date - forever: boolean - workoff: boolean - constructor(options: WorkerConfig) { - this.options = options + constructor(options: WorkerOptions) { + this.options = { ...DEFAULT_OPTIONS, ...options } if (!options?.adapter) { throw new AdapterRequiredError() } + if (!options?.queues || options.queues.length === 0) { + throw new QueuesRequiredError() + } + this.adapter = this.options.adapter this.logger = this.options.logger @@ -58,11 +101,13 @@ export class Worker { // the maximum number of times to retry a failed job this.maxAttempts = this.options.maxAttempts - // the maximum amount of time to let a job run + // the maximum amount of time to let a job run in seconds this.maxRuntime = this.options.maxRuntime + // whether to keep succeeded jobs in the database + this.deleteSuccessfulJobs = this.options.deleteSuccessfulJobs + // whether to keep failed jobs in the database after reaching maxAttempts - // `undefined` check needed here so we can explicitly set to `false` this.deleteFailedJobs = this.options.deleteFailedJobs // the amount of time to wait in milliseconds between checking for jobs. @@ -74,7 +119,7 @@ export class Worker { // running (regardless of how many outstanding jobs there are to be worked // on). The worker process will set this to `false` as soon as the user hits // ctrl-c so any current job will complete before exiting. - this.forever = true + this.forever = this.options.forever // Set to `true` if the work loop should run through all *available* jobs // and then quit. Serves a slightly different purpose than `forever` which @@ -111,6 +156,7 @@ export class Worker { async #work() { do { + console.info('Checking...') this.lastCheckTime = new Date() this.logger.debug( @@ -125,11 +171,13 @@ export class Worker { if (job) { // TODO add timeout handling if runs for more than `this.maxRuntime` + // will need to run Executor in a separate process with a timeout await new Executor({ adapter: this.adapter, logger: this.logger, job, maxAttempts: this.maxAttempts, + deleteSuccessfulJobs: this.deleteSuccessfulJobs, deleteFailedJobs: this.deleteFailedJobs, }).perform() } else if (this.workoff) { diff --git a/packages/jobs/src/core/__tests__/Worker.test.js b/packages/jobs/src/core/__tests__/Worker.test.js index 3d17e6d50067..3f731d01e8bc 100644 --- a/packages/jobs/src/core/__tests__/Worker.test.js +++ b/packages/jobs/src/core/__tests__/Worker.test.js @@ -10,7 +10,7 @@ import { afterEach, } from 'vitest' -import * as errors from '../../core/errors' +import * as errors from '../../errors' import { Executor } from '../Executor' import { Worker, DEFAULTS } from '../Worker' @@ -30,253 +30,252 @@ vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { } }) -vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) +// vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) + +const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +} + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) describe('constructor', () => { it('saves options', () => { - const options = { adapter: 'adapter' } + const options = { adapter: 'adapter', queues: ['*'] } const worker = new Worker(options) expect(worker.options.adapter).toEqual(options.adapter) }) it('extracts adapter from options to variable', () => { - const options = { adapter: 'adapter' } + const options = { adapter: 'adapter', queues: ['*'] } const worker = new Worker(options) expect(worker.adapter).toEqual('adapter') }) it('extracts logger from options to variable', () => { - const options = { adapter: 'adapter', logger: { foo: 'bar' } } + const options = { + adapter: 'adapter', + queues: ['*'], + logger: { foo: 'bar' }, + } const worker = new Worker(options) expect(worker.logger).toEqual({ foo: 'bar' }) }) it('defaults logger if not provided', () => { - const options = { adapter: 'adapter' } + const options = { adapter: 'adapter', queues: ['*'] } const worker = new Worker(options) expect(worker.logger).toEqual(console) }) it('extracts processName from options to variable', () => { - const options = { adapter: 'adapter', processName: 'processName' } + const options = { + adapter: 'adapter', + queues: ['*'], + processName: 'processName', + } const worker = new Worker(options) expect(worker.processName).toEqual('processName') }) - it('defaults processName if not provided', () => { - const options = { adapter: 'adapter' } - const worker = new Worker(options) - - expect(worker.processName).not.toBeUndefined() - }) - it('extracts queue from options to variable', () => { - const options = { adapter: 'adapter', queue: 'queue' } + const options = { adapter: 'adapter', queues: ['default'] } const worker = new Worker(options) - expect(worker.queue).toEqual('queue') - }) - - it('defaults queue if not provided', () => { - const options = { adapter: 'adapter' } - const worker = new Worker(options) - - expect(worker.queue).toBeNull() + expect(worker.queues).toEqual(['default']) }) it('extracts clear from options to variable', () => { - const options = { adapter: 'adapter', clear: true } + const options = { adapter: 'adapter', queues: ['*'], clear: true } const worker = new Worker(options) expect(worker.clear).toEqual(true) }) it('defaults clear if not provided', () => { - const options = { adapter: 'adapter' } + const options = { adapter: 'adapter', queues: ['*'] } const worker = new Worker(options) expect(worker.clear).toEqual(false) }) it('extracts maxAttempts from options to variable', () => { - const options = { adapter: 'adapter', maxAttempts: 10 } + const options = { adapter: 'adapter', queues: ['*'], maxAttempts: 10 } const worker = new Worker(options) expect(worker.maxAttempts).toEqual(10) }) - it('defaults maxAttempts if not provided', () => { - const options = { adapter: 'adapter' } - const worker = new Worker(options) - - expect(worker.maxAttempts).toEqual(DEFAULTS.maxAttempts) - }) - it('extracts maxRuntime from options to variable', () => { - const options = { adapter: 'adapter', maxRuntime: 10 } + const options = { adapter: 'adapter', queues: ['*'], maxRuntime: 10 } const worker = new Worker(options) expect(worker.maxRuntime).toEqual(10) }) - it('defaults maxRuntime if not provided', () => { - const options = { adapter: 'adapter' } - const worker = new Worker(options) - - expect(worker.maxRuntime).toEqual(DEFAULTS.maxRuntime) - }) - it('extracts deleteFailedJobs from options to variable', () => { - const options = { adapter: 'adapter', deleteFailedJobs: 10 } + const options = { adapter: 'adapter', queues: ['*'], deleteFailedJobs: 10 } const worker = new Worker(options) expect(worker.deleteFailedJobs).toEqual(10) }) - it('defaults deleteFailedJobs if not provided', () => { - const options = { adapter: 'adapter' } - const worker = new Worker(options) - - expect(worker.deleteFailedJobs).toEqual(DEFAULTS.deleteFailedJobs) - }) - it('extracts sleepDelay from options to variable', () => { - const options = { adapter: 'adapter', sleepDelay: 5 } + const options = { adapter: 'adapter', queues: ['*'], sleepDelay: 5 } const worker = new Worker(options) expect(worker.sleepDelay).toEqual(5000) }) - it('defaults sleepDelay if not provided', () => { - const options = { adapter: 'adapter' } - const worker = new Worker(options) - - expect(worker.sleepDelay).toEqual(DEFAULTS.sleepDelay * 1000) - }) - it('can set sleepDelay to 0', () => { - const options = { adapter: 'adapter', sleepDelay: 0 } + const options = { adapter: 'adapter', queues: ['*'], sleepDelay: 0 } const worker = new Worker(options) expect(worker.sleepDelay).toEqual(0) }) - it('extracts forever from options to variable', () => { - const options = { adapter: 'adapter', forever: false } - const worker = new Worker(options) - - expect(worker.forever).toEqual(false) - }) - - it('defaults forever if not provided', () => { - const options = { adapter: 'adapter' } + it('sets forever', () => { + const options = { adapter: 'adapter', queues: ['*'] } const worker = new Worker(options) expect(worker.forever).toEqual(true) }) it('extracts workoff from options to variable', () => { - const options = { adapter: 'adapter', workoff: true } + const options = { adapter: 'adapter', queues: ['*'], workoff: true } const worker = new Worker(options) expect(worker.workoff).toEqual(true) }) it('defaults workoff if not provided', () => { - const options = { adapter: 'adapter' } + const options = { adapter: 'adapter', queues: ['*'] } const worker = new Worker(options) expect(worker.workoff).toEqual(false) }) it('sets lastCheckTime to the current time', () => { - const options = { adapter: 'adapter' } + const options = { adapter: 'adapter', queues: ['*'] } const worker = new Worker(options) expect(worker.lastCheckTime).toBeInstanceOf(Date) }) - it('extracts forever from options to variable', () => { - const options = { adapter: 'adapter', forever: false } - const worker = new Worker(options) - - expect(worker.forever).toEqual(false) - }) - - it('defaults forever if not provided', () => { - const options = { adapter: 'adapter' } - const worker = new Worker(options) - - expect(worker.forever).toEqual(true) - }) - it('throws an error if adapter not set', () => { expect(() => new Worker()).toThrow(errors.AdapterRequiredError) }) -}) - -const originalConsoleDebug = console.debug - -describe('run', () => { - beforeAll(() => { - // hide console.debug output during test run - console.debug = vi.fn() - }) - afterEach(() => { - // vi.resetAllMocks() + it('throws an error if queues not set', () => { + expect(() => new Worker()).toThrow(errors.AdapterRequiredError) }) - afterAll(() => { - // reenable console.debug output during test run - console.debug = originalConsoleDebug + it('throws an error if queues is an empty array', () => { + expect(() => new Worker()).toThrow(errors.AdapterRequiredError) }) +}) +describe('run', async () => { it('tries to find a job', async () => { const adapter = { find: vi.fn(() => null) } - const worker = new Worker({ adapter, waitTime: 0, forever: false }) + const worker = new Worker({ + adapter, + queues: ['*'], + sleepDelay: 0, + forever: false, + }) await worker.run() expect(adapter.find).toHaveBeenCalledWith({ processName: worker.processName, maxRuntime: worker.maxRuntime, - queue: worker.queue, + queues: worker.queues, + }) + }) + + it('will try to find jobs in a loop until `forever` is set to `false`', async () => { + const adapter = { find: vi.fn(() => null) } + const worker = new Worker({ + adapter, + queues: ['*'], + sleepDelay: 0.01, + forever: true, }) + + worker.run() + // just enough delay to run through the loop twice + await new Promise((resolve) => setTimeout(resolve, 20)) + worker.forever = false + expect(adapter.find).toHaveBeenCalledTimes(2) }) it('does nothing if no job found and forever=false', async () => { const adapter = { find: vi.fn(() => null) } vi.spyOn(Executor, 'constructor') - const worker = new Worker({ adapter, waitTime: 0, forever: false }) + const worker = new Worker({ + adapter, + queues: ['*'], + sleepDelay: 0, + forever: false, + }) await worker.run() expect(Executor).not.toHaveBeenCalled() }) - it('does nothing if no job found and workoff=true', async () => { + it('exits if no job found and workoff=true', async () => { const adapter = { find: vi.fn(() => null) } vi.spyOn(Executor, 'constructor') - const worker = new Worker({ adapter, waitTime: 0, workoff: true }) + const worker = new Worker({ + adapter, + queues: ['*'], + sleepDelay: 0, + workoff: true, + }) await worker.run() expect(Executor).not.toHaveBeenCalled() }) + it('loops until no job found when workoff=true', async () => { + const adapter = { + find: vi + .fn() + .mockImplementationOnce(() => ({ id: 1 })) + .mockImplementationOnce(() => null), + } + vi.spyOn(Executor, 'constructor') + + const worker = new Worker({ + adapter, + queues: ['*'], + sleepDelay: 0, + workoff: true, + }) + await worker.run() + + expect(Executor).toHaveBeenCalledOnce() + }) + it('initializes an Executor instance if the job is found', async () => { const adapter = { find: vi.fn(() => ({ id: 1 })) } const worker = new Worker({ adapter, - waitTime: 0, + queues: ['*'], + sleepDelay: 0, forever: false, maxAttempts: 10, + deleteSuccessfulJobs: false, deleteFailedJobs: true, }) @@ -287,6 +286,7 @@ describe('run', () => { job: { id: 1 }, logger: worker.logger, maxAttempts: 10, + deleteSuccessfulJobs: false, deleteFailedJobs: true, }) }) @@ -294,17 +294,12 @@ describe('run', () => { it('calls `perform` on the Executor instance', async () => { const adapter = { find: vi.fn(() => ({ id: 1 })) } const spy = vi.spyOn(Executor.prototype, 'perform') - const worker = new Worker({ adapter, waitTime: 0, forever: false }) - - await worker.run() - - expect(spy).toHaveBeenCalled() - }) - - it('calls `perform` on the Executor instance', async () => { - const adapter = { find: vi.fn(() => ({ id: 1 })) } - const spy = vi.spyOn(Executor.prototype, 'perform') - const worker = new Worker({ adapter, waitTime: 0, forever: false }) + const worker = new Worker({ + adapter, + queues: ['*'], + sleepDelay: 0, + forever: false, + }) await worker.run() From 885314298fb2b29f247e2744ac84b9106a2acb76 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 9 Aug 2024 10:49:56 -0700 Subject: [PATCH 165/258] Adds missing queues error --- packages/jobs/src/errors.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/jobs/src/errors.ts b/packages/jobs/src/errors.ts index 8cb5a287164a..f8b0a4d08748 100644 --- a/packages/jobs/src/errors.ts +++ b/packages/jobs/src/errors.ts @@ -25,13 +25,20 @@ export class ModelNameError extends RedwoodJobError { } } -// Thrown when the Executor is instantiated without an adapter +// Thrown when the Worker or Executor is instantiated without an adapter export class AdapterRequiredError extends RedwoodJobError { constructor() { super('`adapter` is required to perform a job') } } +// Thrown when the Worker is instantiated without an array of queues +export class QueuesRequiredError extends RedwoodJobError { + constructor() { + super('`queues` is required to find a job to run') + } +} + // Thrown when the Executor is instantiated without a job export class JobRequiredError extends RedwoodJobError { constructor() { From 0e6a67f4f454636e3a1eb161bb9e0ab5cce3cd25 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 9 Aug 2024 11:00:30 -0700 Subject: [PATCH 166/258] Updates Executor log messages --- packages/jobs/src/core/Executor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/jobs/src/core/Executor.ts b/packages/jobs/src/core/Executor.ts index b0834caf8f84..54dbf57483ac 100644 --- a/packages/jobs/src/core/Executor.ts +++ b/packages/jobs/src/core/Executor.ts @@ -66,7 +66,7 @@ export class Executor { } async perform() { - this.logger.info(this.job, `Started job ${this.job.id}`) + this.logger.info(`Started job ${this.job.id}`) // TODO break these lines down into individual try/catch blocks? try { @@ -90,7 +90,7 @@ export class Executor { if (this.job.attempts >= this.maxAttempts) { this.logger.warn( this.job, - `Failed job ${this.job.id}: reached max attempts`, + `Failed job ${this.job.id}: reached max attempts (${this.maxAttempts})`, ) await this.adapter.failure({ job: this.job, From 8da268d64903320ac93798732d603e9f6e5751b7 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 9 Aug 2024 11:00:37 -0700 Subject: [PATCH 167/258] Remove unused import --- packages/jobs/src/core/__tests__/Executor.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/jobs/src/core/__tests__/Executor.test.js b/packages/jobs/src/core/__tests__/Executor.test.js index 539ad070cdec..90256b76df11 100644 --- a/packages/jobs/src/core/__tests__/Executor.test.js +++ b/packages/jobs/src/core/__tests__/Executor.test.js @@ -3,7 +3,6 @@ import console from 'node:console' import { beforeEach, describe, expect, vi, it } from 'vitest' import * as errors from '../../errors' -import { loadJob } from '../../loaders' import { Executor } from '../Executor' // so that registerApiSideBabelHook() doesn't freak out about redwood.toml From 1daf9f3c0f7167055ae4dd6f3682cf05d349e68e Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 9 Aug 2024 11:00:46 -0700 Subject: [PATCH 168/258] Update mocks --- packages/jobs/src/core/__tests__/mocks.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/jobs/src/core/__tests__/mocks.ts b/packages/jobs/src/core/__tests__/mocks.ts index 0897f8abf821..c35eecbcd4f5 100644 --- a/packages/jobs/src/core/__tests__/mocks.ts +++ b/packages/jobs/src/core/__tests__/mocks.ts @@ -14,6 +14,7 @@ export const mockAdapter = { schedule: vi.fn(() => {}), find: () => null, clear: () => {}, - success: (_job: { handler: string; args: any }) => {}, - failure: (_job: { handler: string; args: any }, _error: Error) => {}, + success: (_options) => {}, + error: (_options) => {}, + failure: (_options) => {}, } From b783022a41c94410ea89052c0c7e6b2cb02fa67f Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 9 Aug 2024 11:02:35 -0700 Subject: [PATCH 169/258] Updates Worker and Executor tests --- .../jobs/src/core/__tests__/Executor.test.js | 10 +- .../jobs/src/core/__tests__/Worker.test.js | 107 +++++++++++------- 2 files changed, 68 insertions(+), 49 deletions(-) diff --git a/packages/jobs/src/core/__tests__/Executor.test.js b/packages/jobs/src/core/__tests__/Executor.test.js index 90256b76df11..54cd6a08e0ce 100644 --- a/packages/jobs/src/core/__tests__/Executor.test.js +++ b/packages/jobs/src/core/__tests__/Executor.test.js @@ -5,8 +5,7 @@ import { beforeEach, describe, expect, vi, it } from 'vitest' import * as errors from '../../errors' import { Executor } from '../Executor' -// so that registerApiSideBabelHook() doesn't freak out about redwood.toml -vi.mock('@redwoodjs/babel-config') +import { mockLogger } from './mocks' vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { const originalCliHelpers = await importOriginal() @@ -29,13 +28,6 @@ vi.mock('../../loaders', () => { } }) -const mockLogger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -} - describe('constructor', () => { it('saves options', () => { const options = { adapter: 'adapter', job: 'job' } diff --git a/packages/jobs/src/core/__tests__/Worker.test.js b/packages/jobs/src/core/__tests__/Worker.test.js index 3f731d01e8bc..79847e581588 100644 --- a/packages/jobs/src/core/__tests__/Worker.test.js +++ b/packages/jobs/src/core/__tests__/Worker.test.js @@ -1,26 +1,17 @@ import console from 'node:console' -import { - describe, - expect, - vi, - it, - beforeAll, - afterAll, - afterEach, -} from 'vitest' +import { describe, expect, vi, it } from 'vitest' import * as errors from '../../errors' import { Executor } from '../Executor' -import { Worker, DEFAULTS } from '../Worker' +import { Worker } from '../Worker' + +import { mockLogger } from './mocks' // don't execute any code inside Executor, just spy on whether functions are // called vi.mock('../Executor') -// so that registerApiSideBabelHook() doesn't freak out about redwood.toml -vi.mock('@redwoodjs/babel-config') - vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { const originalCliHelpers = await importOriginal() @@ -30,27 +21,16 @@ vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { } }) -// vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) - -const mockLogger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -} - -const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) - describe('constructor', () => { it('saves options', () => { - const options = { adapter: 'adapter', queues: ['*'] } + const options = { adapter: 'adapter', logger: mockLogger, queues: ['*'] } const worker = new Worker(options) expect(worker.options.adapter).toEqual(options.adapter) }) it('extracts adapter from options to variable', () => { - const options = { adapter: 'adapter', queues: ['*'] } + const options = { adapter: 'adapter', logger: mockLogger, queues: ['*'] } const worker = new Worker(options) expect(worker.adapter).toEqual('adapter') @@ -59,12 +39,12 @@ describe('constructor', () => { it('extracts logger from options to variable', () => { const options = { adapter: 'adapter', + logger: mockLogger, queues: ['*'], - logger: { foo: 'bar' }, } const worker = new Worker(options) - expect(worker.logger).toEqual({ foo: 'bar' }) + expect(worker.logger).toEqual(mockLogger) }) it('defaults logger if not provided', () => { @@ -77,6 +57,7 @@ describe('constructor', () => { it('extracts processName from options to variable', () => { const options = { adapter: 'adapter', + logger: mockLogger, queues: ['*'], processName: 'processName', } @@ -86,84 +67,123 @@ describe('constructor', () => { }) it('extracts queue from options to variable', () => { - const options = { adapter: 'adapter', queues: ['default'] } + const options = { + adapter: 'adapter', + logger: mockLogger, + queues: ['default'], + } const worker = new Worker(options) expect(worker.queues).toEqual(['default']) }) it('extracts clear from options to variable', () => { - const options = { adapter: 'adapter', queues: ['*'], clear: true } + const options = { + adapter: 'adapter', + logger: mockLogger, + queues: ['*'], + clear: true, + } const worker = new Worker(options) expect(worker.clear).toEqual(true) }) it('defaults clear if not provided', () => { - const options = { adapter: 'adapter', queues: ['*'] } + const options = { adapter: 'adapter', logger: mockLogger, queues: ['*'] } const worker = new Worker(options) expect(worker.clear).toEqual(false) }) it('extracts maxAttempts from options to variable', () => { - const options = { adapter: 'adapter', queues: ['*'], maxAttempts: 10 } + const options = { + adapter: 'adapter', + logger: mockLogger, + queues: ['*'], + maxAttempts: 10, + } const worker = new Worker(options) expect(worker.maxAttempts).toEqual(10) }) it('extracts maxRuntime from options to variable', () => { - const options = { adapter: 'adapter', queues: ['*'], maxRuntime: 10 } + const options = { + adapter: 'adapter', + logger: mockLogger, + queues: ['*'], + maxRuntime: 10, + } const worker = new Worker(options) expect(worker.maxRuntime).toEqual(10) }) it('extracts deleteFailedJobs from options to variable', () => { - const options = { adapter: 'adapter', queues: ['*'], deleteFailedJobs: 10 } + const options = { + adapter: 'adapter', + logger: mockLogger, + queues: ['*'], + deleteFailedJobs: 10, + } const worker = new Worker(options) expect(worker.deleteFailedJobs).toEqual(10) }) it('extracts sleepDelay from options to variable', () => { - const options = { adapter: 'adapter', queues: ['*'], sleepDelay: 5 } + const options = { + adapter: 'adapter', + logger: mockLogger, + queues: ['*'], + sleepDelay: 5, + } const worker = new Worker(options) expect(worker.sleepDelay).toEqual(5000) }) it('can set sleepDelay to 0', () => { - const options = { adapter: 'adapter', queues: ['*'], sleepDelay: 0 } + const options = { + adapter: 'adapter', + logger: mockLogger, + queues: ['*'], + sleepDelay: 0, + } const worker = new Worker(options) expect(worker.sleepDelay).toEqual(0) }) it('sets forever', () => { - const options = { adapter: 'adapter', queues: ['*'] } + const options = { adapter: 'adapter', logger: mockLogger, queues: ['*'] } const worker = new Worker(options) expect(worker.forever).toEqual(true) }) it('extracts workoff from options to variable', () => { - const options = { adapter: 'adapter', queues: ['*'], workoff: true } + const options = { + adapter: 'adapter', + logger: mockLogger, + queues: ['*'], + workoff: true, + } const worker = new Worker(options) expect(worker.workoff).toEqual(true) }) it('defaults workoff if not provided', () => { - const options = { adapter: 'adapter', queues: ['*'] } + const options = { adapter: 'adapter', logger: mockLogger, queues: ['*'] } const worker = new Worker(options) expect(worker.workoff).toEqual(false) }) it('sets lastCheckTime to the current time', () => { - const options = { adapter: 'adapter', queues: ['*'] } + const options = { adapter: 'adapter', logger: mockLogger, queues: ['*'] } const worker = new Worker(options) expect(worker.lastCheckTime).toBeInstanceOf(Date) @@ -187,6 +207,7 @@ describe('run', async () => { const adapter = { find: vi.fn(() => null) } const worker = new Worker({ adapter, + logger: mockLogger, queues: ['*'], sleepDelay: 0, forever: false, @@ -205,6 +226,7 @@ describe('run', async () => { const adapter = { find: vi.fn(() => null) } const worker = new Worker({ adapter, + logger: mockLogger, queues: ['*'], sleepDelay: 0.01, forever: true, @@ -223,6 +245,7 @@ describe('run', async () => { const worker = new Worker({ adapter, + logger: mockLogger, queues: ['*'], sleepDelay: 0, forever: false, @@ -238,6 +261,7 @@ describe('run', async () => { const worker = new Worker({ adapter, + logger: mockLogger, queues: ['*'], sleepDelay: 0, workoff: true, @@ -258,6 +282,7 @@ describe('run', async () => { const worker = new Worker({ adapter, + logger: mockLogger, queues: ['*'], sleepDelay: 0, workoff: true, @@ -271,6 +296,7 @@ describe('run', async () => { const adapter = { find: vi.fn(() => ({ id: 1 })) } const worker = new Worker({ adapter, + logger: mockLogger, queues: ['*'], sleepDelay: 0, forever: false, @@ -296,6 +322,7 @@ describe('run', async () => { const spy = vi.spyOn(Executor.prototype, 'perform') const worker = new Worker({ adapter, + logger: mockLogger, queues: ['*'], sleepDelay: 0, forever: false, From 4d049e4996c0ed83e756bab494eb13fa8b88524e Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 9 Aug 2024 11:10:11 -0700 Subject: [PATCH 170/258] Remove need to mock isTypescriptProject() --- .../src/adapters/__tests__/PrismaAdapter.test.ts | 9 --------- packages/jobs/src/core/__tests__/Executor.test.js | 9 --------- packages/jobs/src/core/__tests__/JobManager.test.js | 0 packages/jobs/src/core/__tests__/RedwoodJob.test.ts | 13 ++----------- packages/jobs/src/core/__tests__/Scheduler.test.ts | 9 --------- packages/jobs/src/core/__tests__/Worker.test.js | 9 --------- .../jobs/src/core/__tests__/createScheduler.test.ts | 9 --------- packages/jobs/src/errors.ts | 5 +---- 8 files changed, 3 insertions(+), 60 deletions(-) create mode 100644 packages/jobs/src/core/__tests__/JobManager.test.js diff --git a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts index 4cfbd26e5f88..e4dd71a637ca 100644 --- a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts +++ b/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts @@ -9,15 +9,6 @@ import { PrismaAdapter } from '../PrismaAdapter' vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) -vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { - const originalCliHelpers = await importOriginal() - - return { - ...originalCliHelpers, - isTypeScriptProject: () => false, - } -}) - let mockDb: PrismaClient const mockLogger = { diff --git a/packages/jobs/src/core/__tests__/Executor.test.js b/packages/jobs/src/core/__tests__/Executor.test.js index 54cd6a08e0ce..ecda4dc86e4c 100644 --- a/packages/jobs/src/core/__tests__/Executor.test.js +++ b/packages/jobs/src/core/__tests__/Executor.test.js @@ -7,15 +7,6 @@ import { Executor } from '../Executor' import { mockLogger } from './mocks' -vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { - const originalCliHelpers = await importOriginal() - - return { - ...originalCliHelpers, - isTypeScriptProject: () => false, - } -}) - const mocks = vi.hoisted(() => { return { loadJob: vi.fn(), diff --git a/packages/jobs/src/core/__tests__/JobManager.test.js b/packages/jobs/src/core/__tests__/JobManager.test.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/jobs/src/core/__tests__/RedwoodJob.test.ts b/packages/jobs/src/core/__tests__/RedwoodJob.test.ts index 1573b067bbd3..c6147e9bf947 100644 --- a/packages/jobs/src/core/__tests__/RedwoodJob.test.ts +++ b/packages/jobs/src/core/__tests__/RedwoodJob.test.ts @@ -2,19 +2,10 @@ import { describe, expect, vi, it, beforeEach } from 'vitest' import type CliHelpers from '@redwoodjs/cli-helpers' -import { DEFAULT_LOGGER, DEFAULT_QUEUE, DEFAULT_PRIORITY } from '../consts' -import * as errors from '../errors' +import { DEFAULT_LOGGER, DEFAULT_QUEUE, DEFAULT_PRIORITY } from '../../consts' +import * as errors from '../../errors' import { RedwoodJob } from '../RedwoodJob' -vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { - const originalCliHelpers = await importOriginal() - - return { - ...originalCliHelpers, - isTypeScriptProject: () => true, - } -}) - const FAKE_NOW = new Date('2024-01-01') vi.useFakeTimers().setSystemTime(FAKE_NOW) diff --git a/packages/jobs/src/core/__tests__/Scheduler.test.ts b/packages/jobs/src/core/__tests__/Scheduler.test.ts index 1603da67c653..0c3772df3e2b 100644 --- a/packages/jobs/src/core/__tests__/Scheduler.test.ts +++ b/packages/jobs/src/core/__tests__/Scheduler.test.ts @@ -14,15 +14,6 @@ import { Scheduler } from '../Scheduler.js' import { mockAdapter, mockLogger } from './mocks.ts' -vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { - const originalCliHelpers = await importOriginal() - - return { - ...originalCliHelpers, - isTypeScriptProject: () => true, - } -}) - const FAKE_NOW = new Date('2024-01-01') vi.useFakeTimers().setSystemTime(FAKE_NOW) diff --git a/packages/jobs/src/core/__tests__/Worker.test.js b/packages/jobs/src/core/__tests__/Worker.test.js index 79847e581588..c328d7126510 100644 --- a/packages/jobs/src/core/__tests__/Worker.test.js +++ b/packages/jobs/src/core/__tests__/Worker.test.js @@ -12,15 +12,6 @@ import { mockLogger } from './mocks' // called vi.mock('../Executor') -vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { - const originalCliHelpers = await importOriginal() - - return { - ...originalCliHelpers, - isTypeScriptProject: () => false, - } -}) - describe('constructor', () => { it('saves options', () => { const options = { adapter: 'adapter', logger: mockLogger, queues: ['*'] } diff --git a/packages/jobs/src/core/__tests__/createScheduler.test.ts b/packages/jobs/src/core/__tests__/createScheduler.test.ts index c1dcaedb6512..9bcb61b6e10b 100644 --- a/packages/jobs/src/core/__tests__/createScheduler.test.ts +++ b/packages/jobs/src/core/__tests__/createScheduler.test.ts @@ -10,15 +10,6 @@ import { mockAdapter } from './mocks' vi.mock('../Scheduler') -vi.mock('@redwoodjs/cli-helpers', async (importOriginal) => { - const originalCliHelpers = await importOriginal() - - return { - ...originalCliHelpers, - isTypeScriptProject: () => true, - } -}) - describe('createScheduler', () => { it('returns a function', () => { const scheduler = createScheduler({ diff --git a/packages/jobs/src/errors.ts b/packages/jobs/src/errors.ts index f8b0a4d08748..9e3829be1253 100644 --- a/packages/jobs/src/errors.ts +++ b/packages/jobs/src/errors.ts @@ -1,7 +1,4 @@ -// @ts-expect-error - doesn't understand dual CJS/ESM export -import { isTypeScriptProject } from '@redwoodjs/cli-helpers' - -const JOBS_CONFIG_FILENAME = isTypeScriptProject() ? 'jobs.ts' : 'jobs.js' +const JOBS_CONFIG_FILENAME = 'jobs.ts/js' // Parent class for any RedwoodJob-related error export class RedwoodJobError extends Error { From aeb9de0529f2ec05601d1f2cf7b366e87fad120e Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 9 Aug 2024 11:26:58 -0700 Subject: [PATCH 171/258] Updated types --- packages/jobs/src/core/Worker.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/jobs/src/core/Worker.ts b/packages/jobs/src/core/Worker.ts index dec561aca17d..191e0df3e84e 100644 --- a/packages/jobs/src/core/Worker.ts +++ b/packages/jobs/src/core/Worker.ts @@ -17,11 +17,13 @@ import type { BasicLogger } from '../types' import { Executor } from './Executor' interface WorkerOptions { + // required adapter: BaseAdapter - logger?: BasicLogger - clear?: boolean processName: string queues: string[] + // optional + logger?: BasicLogger + clear?: boolean maxAttempts?: number maxRuntime?: number deleteSuccessfulJobs?: boolean @@ -45,7 +47,7 @@ interface DefaultOptions { forever: WorkerOptions['forever'] } -type CompleteOptions = WorkerOptions & DefaultOptions +type CompleteOptions = Required const DEFAULT_OPTIONS: DefaultOptions = { logger: DEFAULT_LOGGER, From b3efb1996f5227d9dd52df504d094b2c73fb9052 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 9 Aug 2024 15:47:50 -0700 Subject: [PATCH 172/258] Add JobManager tests --- .../src/core/__tests__/JobManager.test.js | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/packages/jobs/src/core/__tests__/JobManager.test.js b/packages/jobs/src/core/__tests__/JobManager.test.js index e69de29bb2d1..5d60d95942d9 100644 --- a/packages/jobs/src/core/__tests__/JobManager.test.js +++ b/packages/jobs/src/core/__tests__/JobManager.test.js @@ -0,0 +1,139 @@ +import { describe, expect, vi, it, beforeEach } from 'vitest' + +import { JobManager } from '../JobManager' +import { Scheduler } from '../Scheduler' + +import { mockAdapter, mockLogger } from './mocks' + +vi.mock('../Scheduler') + +describe('constructor', () => { + let manager, workers + + beforeEach(() => { + workers = [ + { + adapter: 'mock', + queue: '*', + count: 1, + }, + ] + + manager = new JobManager({ + adapters: { + mock: mockAdapter, + }, + queues: ['queue'], + logger: mockLogger, + workers, + }) + }) + + it('saves adapters', () => { + expect(manager.adapters).toEqual({ mock: mockAdapter }) + }) + + it('saves queues', () => { + expect(manager.queues).toEqual(['queue']) + }) + + it('saves logger', () => { + expect(manager.logger).toEqual(mockLogger) + }) + + it('saves workers', () => { + expect(manager.workers).toEqual(workers) + }) +}) + +describe('createScheduler()', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('returns a function', () => { + const manager = new JobManager({ + adapters: { + mock: mockAdapter, + }, + queues: [], + logger: mockLogger, + workers: [], + }) + + const scheduler = manager.createScheduler({ adapter: 'mock' }) + + expect(scheduler).toBeInstanceOf(Function) + }) + + it('initializes the scheduler with the correct adapter', () => { + const manager = new JobManager({ + adapters: { + mock: mockAdapter, + }, + queues: ['*'], + logger: mockLogger, + workers: [], + }) + manager.createScheduler({ adapter: 'mock', logger: mockLogger }) + + expect(Scheduler).toHaveBeenCalledWith( + expect.objectContaining({ adapter: mockAdapter }), + ) + }) + + it('initializes the scheduler with a logger', () => { + const manager = new JobManager({ + adapters: { + mock: mockAdapter, + }, + queues: [], + logger: mockLogger, + workers: [], + }) + manager.createScheduler({ adapter: 'mock', logger: mockLogger }) + + expect(Scheduler).toHaveBeenCalledWith( + expect.objectContaining({ logger: mockLogger }), + ) + }) + + it('calling the function invokes the schedule() method of the scheduler', () => { + const manager = new JobManager({ + adapters: { + mock: mockAdapter, + }, + queues: [], + logger: mockLogger, + workers: [], + }) + const mockJob = { perform: () => {} } + const mockArgs = ['foo'] + const mockOptions = { wait: 300 } + const scheduler = manager.createScheduler({ adapter: 'mock' }) + + scheduler(mockJob, mockArgs, mockOptions) + + expect(Scheduler.prototype.schedule).toHaveBeenCalledWith({ + job: mockJob, + jobArgs: mockArgs, + jobOptions: mockOptions, + }) + }) +}) + +describe('createJob()', () => { + it('returns the same job description that was passed in', () => { + const manager = new JobManager({ + adapters: {}, + queues: [], + logger: mockLogger, + workers: [], + }) + const jobDefinition = { perform: () => {} } + + const job = manager.createJob(jobDefinition) + + expect(job).toEqual(jobDefinition) + }) +}) From 29268bc6cf2595f8a28edbc0f4e6b6b141185fa9 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 9 Aug 2024 15:47:57 -0700 Subject: [PATCH 173/258] Remove debug --- packages/jobs/src/core/Worker.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/jobs/src/core/Worker.ts b/packages/jobs/src/core/Worker.ts index 191e0df3e84e..ac1af3e2de2f 100644 --- a/packages/jobs/src/core/Worker.ts +++ b/packages/jobs/src/core/Worker.ts @@ -158,7 +158,6 @@ export class Worker { async #work() { do { - console.info('Checking...') this.lastCheckTime = new Date() this.logger.debug( From 2ce3b428894b41f20def1beaf582ccf59f870359 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 9 Aug 2024 15:48:07 -0700 Subject: [PATCH 174/258] Remove old tests --- .../src/core/__tests__/RedwoodJob.test.ts | 444 ------------------ .../{Scheduler.test.ts => Scheduler.old.ts} | 0 .../core/__tests__/createScheduler.test.ts | 51 -- .../src/core/__typetests__/RedwoodJob.test.ts | 154 ------ 4 files changed, 649 deletions(-) delete mode 100644 packages/jobs/src/core/__tests__/RedwoodJob.test.ts rename packages/jobs/src/core/__tests__/{Scheduler.test.ts => Scheduler.old.ts} (100%) delete mode 100644 packages/jobs/src/core/__tests__/createScheduler.test.ts delete mode 100644 packages/jobs/src/core/__typetests__/RedwoodJob.test.ts diff --git a/packages/jobs/src/core/__tests__/RedwoodJob.test.ts b/packages/jobs/src/core/__tests__/RedwoodJob.test.ts deleted file mode 100644 index c6147e9bf947..000000000000 --- a/packages/jobs/src/core/__tests__/RedwoodJob.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { describe, expect, vi, it, beforeEach } from 'vitest' - -import type CliHelpers from '@redwoodjs/cli-helpers' - -import { DEFAULT_LOGGER, DEFAULT_QUEUE, DEFAULT_PRIORITY } from '../../consts' -import * as errors from '../../errors' -import { RedwoodJob } from '../RedwoodJob' - -const FAKE_NOW = new Date('2024-01-01') -vi.useFakeTimers().setSystemTime(FAKE_NOW) - -const mockLogger = { - log: vi.fn(() => {}), - info: vi.fn(() => {}), - debug: vi.fn(() => {}), - warn: vi.fn(() => {}), - error: vi.fn(() => {}), -} - -const mockAdapter = { - options: {}, - logger: mockLogger, - schedule: vi.fn(() => {}), - find: () => null, - clear: () => {}, - success: (_job: { handler: string; args: any }) => {}, - failure: (_job: { handler: string; args: any }, _error: Error) => {}, -} - -class TestJob extends RedwoodJob { - static adapter = mockAdapter - - async perform() { - return 'done' - } -} - -describe('static properties', () => { - it('sets a default logger', () => { - expect(RedwoodJob.logger).toEqual(DEFAULT_LOGGER) - }) - - it('sets a default queue', () => { - expect(RedwoodJob.queue).toEqual(DEFAULT_QUEUE) - }) - - it('sets a default priority', () => { - expect(RedwoodJob.priority).toEqual(DEFAULT_PRIORITY) - }) -}) - -describe('static config()', () => { - it('can set the adapter', () => { - RedwoodJob.config({ adapter: mockAdapter }) - - expect(RedwoodJob.adapter).toEqual(mockAdapter) - }) - - it('can set the logger', () => { - RedwoodJob.config({ adapter: mockAdapter, logger: mockLogger }) - - expect(RedwoodJob.logger).toEqual(mockLogger) - }) - - it('is inherited by subclasses', () => { - RedwoodJob.config({ adapter: mockAdapter }) - - expect(TestJob.adapter).toEqual(mockAdapter) - }) -}) - -describe('constructor()', () => { - it('returns an instance of the job', () => { - const job = new TestJob() - expect(job).toBeInstanceOf(RedwoodJob) - }) - - it('can set options for the job', () => { - const job = new TestJob({ wait: 5 }) - expect(job.options.wait).toEqual(5) - }) - - it('throws an error if no adapter is configured', async () => { - // @ts-expect-error - testing JS scenario - class AdapterlessJob extends RedwoodJob { - static adapter = undefined - } - - expect(() => new AdapterlessJob()).toThrow(errors.AdapterNotConfiguredError) - }) -}) - -describe('static set()', () => { - it('returns a job instance', () => { - const job = TestJob.set({ wait: 300 }) - - expect(job).toBeInstanceOf(TestJob) - }) - - it('can override the queue name set in the class', () => { - const job = TestJob.set({ priority: 5, queue: 'priority' }) - - expect(job.options.queue).toEqual('priority') - }) - - it('can override the priority set in the class', () => { - const job = TestJob.set({ queue: 'bar', priority: 10 }) - - expect(job.options.priority).toEqual(10) - }) -}) - -describe('instance set()', () => { - it('returns a job instance', () => { - const job = new TestJob().set() - - expect(job).toBeInstanceOf(TestJob) - }) - - it('sets options for the job', () => { - const job = new TestJob().set({ queue: 'foo', priority: 10, wait: 300 }) - - expect(job.options).toEqual({ queue: 'foo', priority: 10, wait: 300 }) - }) - - it('overrides initialization options', () => { - const job = new TestJob({ queue: 'foo' }) - job.set({ queue: 'bar' }) - - expect(job.queue).toEqual('bar') - }) - - it('does not override different options', () => { - const job = new TestJob({ priority: 10 }) - job.set({ queue: 'foo' }) - - expect(job.priority).toEqual(10) - expect(job.queue).toEqual('foo') - }) - - it('can override the static (class) queue', () => { - const job = new TestJob() - expect(job.queue).toEqual(DEFAULT_QUEUE) - - job.set({ queue: 'random' }) - expect(job.options.queue).toEqual('random') - }) - - it('can override the static (class) priority', () => { - const job = new TestJob() - expect(job.priority).toEqual(DEFAULT_PRIORITY) - - job.set({ priority: 10 }) - expect(job.options.priority).toEqual(10) - }) -}) - -describe('get options()', () => { - it('returns the options set in the class', () => { - const job = new TestJob({ queue: 'foo' }) - - expect(job.options).toEqual({ queue: 'foo' }) - }) -}) - -describe('get adapter()', () => { - it('returns the adapter set in the class', () => { - const job = new TestJob() - - expect(job.adapter).toEqual(mockAdapter) - }) -}) - -describe('get logger()', () => { - it('returns the logger set in the class', () => { - const job = new TestJob() - - expect(job.logger).toEqual(mockLogger) - }) -}) - -describe('get queue()', () => { - it('returns the queue set in the class if no option set', () => { - const job = new TestJob() - - expect(job.queue).toEqual(DEFAULT_QUEUE) - }) - - it('returns the queue set in the options', () => { - const job = new TestJob({ queue: 'foo' }) - - expect(job.queue).toEqual('foo') - }) -}) - -describe('get priority()', () => { - it('returns the priority set in the class if no option set', () => { - const job = new TestJob() - - expect(job.priority).toEqual(DEFAULT_PRIORITY) - }) - - it('returns the priority set in the options', () => { - const job = new TestJob({ priority: 10 }) - - expect(job.priority).toEqual(10) - }) -}) - -describe('get wait()', () => { - it('returns the wait set in the options', () => { - const job = new TestJob({ wait: 10 }) - - expect(job.wait).toEqual(10) - }) -}) - -describe('get waitUntil()', () => { - it('returns the waitUntil set in the options', () => { - const futureDate = new Date(2025, 0, 1) - const job = new TestJob({ waitUntil: futureDate }) - - expect(job.waitUntil).toEqual(futureDate) - }) -}) - -describe('get runAt()', () => { - it('returns the current time if no options are set', () => { - const job = new TestJob() - - expect(job.runAt).toEqual(new Date()) - }) - - it('returns a datetime `wait` seconds in the future if option set', async () => { - const job = new TestJob().set({ wait: 300 }) - - const nowPlus300s = new Date(FAKE_NOW.getTime() + 300 * 1000) - expect(job.runAt).toEqual(nowPlus300s) - }) - - it('returns a datetime set to `waitUntil` if option set', async () => { - const futureDate = new Date(2030, 1, 2, 12, 34, 56) - const job = new TestJob().set({ - waitUntil: futureDate, - }) - - expect(job.runAt).toEqual(futureDate) - }) -}) - -describe('static performLater()', () => { - beforeEach(() => { - vi.resetAllMocks() - }) - - it('invokes the instance performLater()', () => { - const spy = vi.spyOn(TestJob.prototype, 'performLater') - RedwoodJob.config({ adapter: mockAdapter, logger: mockLogger }) - - TestJob.performLater('foo', 'bar') - - expect(spy).toHaveBeenCalledWith('foo', 'bar') - }) -}) - -describe('instance performLater()', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('logs that the job is being scheduled', async () => { - TestJob.config({ adapter: mockAdapter, logger: mockLogger }) - - await new TestJob().performLater('foo', 'bar') - - expect(mockLogger.info).toHaveBeenCalledWith( - { - args: ['foo', 'bar'], - handler: 'TestJob', - priority: 50, - queue: 'default', - runAt: new Date(), - }, - '[RedwoodJob] Scheduling TestJob', - ) - }) - - it('calls the `schedule` function on the adapter', async () => { - RedwoodJob.config({ adapter: mockAdapter, logger: mockLogger }) - - await new TestJob().performLater('foo', 'bar') - - expect(mockAdapter.schedule).toHaveBeenCalledWith({ - handler: 'TestJob', - args: ['foo', 'bar'], - queue: 'default', - priority: 50, - runAt: new Date(), - }) - }) - - it('returns whatever the adapter returns', async () => { - const scheduleReturn = { status: 'scheduled' } - const adapter = { - ...mockAdapter, - schedule: vi.fn(() => scheduleReturn), - } - TestJob.config({ adapter, logger: mockLogger }) - - const result = await new TestJob().performLater('foo', 'bar') - - expect(result).toEqual(scheduleReturn) - }) - - it('catches any errors thrown during schedulding and throws custom error', async () => { - const adapter = { - ...mockAdapter, - schedule: vi.fn(() => { - throw new Error('Could not schedule') - }), - } - RedwoodJob.config({ adapter, logger: mockLogger }) - - try { - await new TestJob().performLater('foo', 'bar') - } catch (e) { - expect(e).toBeInstanceOf(errors.SchedulingError) - expect(e.message).toEqual( - '[RedwoodJob] Exception when scheduling TestJob', - ) - expect(e.originalError.message).toEqual('Could not schedule') - } - }) -}) - -describe('static performNow()', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('invokes the instance performNow()', () => { - const spy = vi.spyOn(TestJob.prototype, 'performNow') - RedwoodJob.config({ adapter: mockAdapter }) - - TestJob.performNow('foo', 'bar') - - expect(spy).toHaveBeenCalledWith('foo', 'bar') - }) -}) - -describe('instance performNow()', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('throws an error if perform() function is not implemented', async () => { - // @ts-expect-error - testing JS scenario - class TestJob extends RedwoodJob {} - const job = new TestJob() - - expect(() => job.perform('foo', 'bar')).toThrow(TypeError) - }) - - it('re-throws perform() error from performNow() if perform() function is not implemented', async () => { - RedwoodJob.config({ adapter: mockAdapter, logger: mockLogger }) - - // @ts-expect-error - testing JS scenario - class TestJob extends RedwoodJob {} - const job = new TestJob() - - expect(() => job.performNow('foo', 'bar')).toThrow(TypeError) - }) - - it('logs that the job is being run', async () => { - TestJob.config({ adapter: mockAdapter, logger: mockLogger }) - - await new TestJob().performNow('foo', 'bar') - - expect(mockLogger.info).toHaveBeenCalledWith( - { - args: ['foo', 'bar'], - handler: 'TestJob', - priority: 50, - queue: 'default', - runAt: new Date(), - }, - '[RedwoodJob] Running TestJob now', - ) - }) - - it('invokes the perform() function immediately', async () => { - const spy = vi.spyOn(TestJob.prototype, 'perform') - - await new TestJob().performNow('foo', 'bar') - - expect(spy).toHaveBeenCalledWith('foo', 'bar') - }) - - it('returns whatever the perform() function returns', async () => { - const performReturn = { status: 'done' } - class TestJob extends RedwoodJob { - async perform() { - return performReturn - } - } - - const result = await new TestJob().performNow('foo', 'bar') - - expect(result).toEqual(performReturn) - }) - - it('catches any errors thrown during perform and throws custom error', async () => { - class TestJobPerf extends RedwoodJob { - perform() { - throw new Error('Could not perform') - } - } - const adapter = { - ...mockAdapter, - schedule: vi.fn(() => { - throw new Error('Could not schedule') - }), - } - - RedwoodJob.config({ adapter, logger: mockLogger }) - - try { - new TestJobPerf().performNow('foo', 'bar') - } catch (e) { - expect(e).toBeInstanceOf(errors.PerformError) - expect(e.message).toEqual('[TestJobPerf] exception when running job') - expect(e.originalError.message).toEqual('Could not perform') - } - }) -}) - -describe('perform()', () => { - it('throws an error if not implemented', () => { - // @ts-expect-error - testing JS scenario - const job = new RedwoodJob() - - expect(() => job.perform()).toThrow(TypeError) - }) -}) diff --git a/packages/jobs/src/core/__tests__/Scheduler.test.ts b/packages/jobs/src/core/__tests__/Scheduler.old.ts similarity index 100% rename from packages/jobs/src/core/__tests__/Scheduler.test.ts rename to packages/jobs/src/core/__tests__/Scheduler.old.ts diff --git a/packages/jobs/src/core/__tests__/createScheduler.test.ts b/packages/jobs/src/core/__tests__/createScheduler.test.ts deleted file mode 100644 index 9bcb61b6e10b..000000000000 --- a/packages/jobs/src/core/__tests__/createScheduler.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { idText } from 'typescript' -import { describe, expect, vi, it } from 'vitest' - -import type CliHelpers from '@redwoodjs/cli-helpers' - -import { createScheduler } from '../createScheduler' -import { Scheduler } from '../Scheduler' - -import { mockAdapter } from './mocks' - -vi.mock('../Scheduler') - -describe('createScheduler', () => { - it('returns a function', () => { - const scheduler = createScheduler({ - adapter: mockAdapter, - }) - - expect(scheduler).toBeInstanceOf(Function) - }) - - it('creates an instance of the JobScheduler when the resulting function is called', () => { - const config = { adapter: mockAdapter } - const job = () => {} - const jobArgs = ['foo'] - const jobOptions = { wait: 300 } - const scheduler = createScheduler(config) - - scheduler(job, jobArgs, jobOptions) - - expect(Scheduler).toHaveBeenCalledWith({ - config, - job, - jobArgs, - jobOptions, - }) - }) - - it('calls the `schedule` method on the JobScheduler instance', () => { - const config = { adapter: mockAdapter } - const job = () => {} - const jobArgs = ['foo'] - const jobOptions = { wait: 300 } - const scheduler = createScheduler(config) - const spy = vi.spyOn(Scheduler.prototype, 'schedule') - - scheduler(job, jobArgs, jobOptions) - - expect(spy).toHaveBeenCalled() - }) -}) diff --git a/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts b/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts deleted file mode 100644 index 8a2d902ec4d0..000000000000 --- a/packages/jobs/src/core/__typetests__/RedwoodJob.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, it } from 'tstyche' - -import { RedwoodJob } from '../RedwoodJob' - -describe('perform()', () => { - it('respects the types of its arguments', () => { - class TypeSafeJob extends RedwoodJob<[number]> { - perform(id: number) { - return id - } - } - - expect(new TypeSafeJob().perform).type.toBe<(id: number) => number>() - }) -}) - -describe('performNow()', () => { - type TPerformArgs = [{ id: string }] - - it('has the same function signature as perform()', () => { - class TypeSafeJob extends RedwoodJob { - perform({ id }: TPerformArgs[0]) { - return id.toUpperCase() - } - } - - const job = new TypeSafeJob() - - expect(job.performNow).type.toBe<(args: TPerformArgs[0]) => string>() - expect(job.performNow).type.toBe() - expect(job.performNow).type.toBe(job.perform) - }) - - it('has the correct return type', () => { - class TypeSafeJob extends RedwoodJob { - perform({ id }: TPerformArgs[0]) { - return id.toUpperCase() - } - } - - const job = new TypeSafeJob() - const returnValue = job.performNow({ id: 'id_1' }) - - // Regular method - expect(returnValue).type.toBeString() - - // Static method - expect(TypeSafeJob.performNow({ id: 'id_2' })).type.toBeString() - }) - - it('can take more than one parameter in a typesafe way', () => { - type TPerformArgs = [number, string] - - class TypeSafeJob extends RedwoodJob { - perform(num: number, str: string) { - return { num, str } - } - } - - const job = new TypeSafeJob() - const returnValue = job.performNow(1, 'str') - - expect(returnValue).type.toBe<{ num: number; str: string }>() - }) -}) - -describe('performLater()', () => { - type TPerformArgs = [{ id: string }] - - it('has the same arg types as perform()', () => { - class TypeSafeJob extends RedwoodJob { - perform({ id }: TPerformArgs[0]) { - return id.toUpperCase() - } - } - - const job = new TypeSafeJob() - - expect>().type.toBe() - expect>().type.toBe< - Parameters<(typeof job)['perform']> - >() - }) - - it('has the correct return type', () => { - class TypeSafeJob extends RedwoodJob { - perform({ id }: TPerformArgs[0]) { - return id.toUpperCase() - } - } - - const job = new TypeSafeJob() - const returnValue = job.performLater({ id: 'id_1' }) - - // Regular method - // TODO: Fix this test. It should probably not be `.toBeString()` - expect(returnValue).type.toBeString() - - // Static method - // TODO: Fix this test. It should probably not be `.toBeString()` - expect(TypeSafeJob.performLater({ id: 'id_2' })).type.toBeString() - }) - - it('can take more than one parameter in a typesafe way', () => { - type TPerformArgs = [number, string] - - class TypeSafeJob extends RedwoodJob { - perform(num: number, str: string) { - return { num, str } - } - } - - const job = new TypeSafeJob() - const returnValue = job.performLater(1, 'str') - - expect(returnValue).type.toBe<{ num: number; str: string }>() - }) - - it('errors with the wrong number of args', () => { - type TPerformArgs = [number, string] - - class TypeSafeJob extends RedwoodJob { - perform(num: number, str: string) { - return { num, str } - } - } - - const job = new TypeSafeJob() - - expect(job.performLater(4)).type.toRaiseError( - 'Expected 2 arguments, but got 1', - ) - - expect(job.performLater(4, 'bar', 'baz')).type.toRaiseError( - 'Expected 2 arguments, but got 3', - ) - }) - - it('errors with the wrong type of args', () => { - type TPerformArgs = [number, string] - - class TypeSafeJob extends RedwoodJob { - perform(num: number, str: string) { - return { num, str } - } - } - - const job = new TypeSafeJob() - - expect(job.performLater(4, 5)).type.toRaiseError( - "Argument of type 'number' is not assignable to parameter of type 'string'.", - ) - }) -}) From ebe7d39fb7ef392337f0d10f93be1fce781b68ae Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 9 Aug 2024 15:48:25 -0700 Subject: [PATCH 175/258] Reset mocks properly in Worker tests --- packages/jobs/src/core/__tests__/Worker.test.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/jobs/src/core/__tests__/Worker.test.js b/packages/jobs/src/core/__tests__/Worker.test.js index c328d7126510..eae0ccbb1c98 100644 --- a/packages/jobs/src/core/__tests__/Worker.test.js +++ b/packages/jobs/src/core/__tests__/Worker.test.js @@ -1,6 +1,6 @@ import console from 'node:console' -import { describe, expect, vi, it } from 'vitest' +import { beforeEach, describe, expect, vi, it } from 'vitest' import * as errors from '../../errors' import { Executor } from '../Executor' @@ -194,6 +194,10 @@ describe('constructor', () => { }) describe('run', async () => { + beforeEach(() => { + vi.resetAllMocks() + }) + it('tries to find a job', async () => { const adapter = { find: vi.fn(() => null) } const worker = new Worker({ @@ -269,7 +273,7 @@ describe('run', async () => { .mockImplementationOnce(() => ({ id: 1 })) .mockImplementationOnce(() => null), } - vi.spyOn(Executor, 'constructor') + //vi.spyOn(Executor, 'constructor') const worker = new Worker({ adapter, @@ -310,7 +314,6 @@ describe('run', async () => { it('calls `perform` on the Executor instance', async () => { const adapter = { find: vi.fn(() => ({ id: 1 })) } - const spy = vi.spyOn(Executor.prototype, 'perform') const worker = new Worker({ adapter, logger: mockLogger, @@ -321,6 +324,6 @@ describe('run', async () => { await worker.run() - expect(spy).toHaveBeenCalled() + expect(Executor.prototype.perform).toHaveBeenCalled() }) }) From 08ace1595f189b5126fcb3bb88d6df78c6ace0c1 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 9 Aug 2024 15:50:29 -0700 Subject: [PATCH 176/258] Start of Scheduler tests --- .../jobs/src/core/__tests__/Scheduler.test.js | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 packages/jobs/src/core/__tests__/Scheduler.test.js diff --git a/packages/jobs/src/core/__tests__/Scheduler.test.js b/packages/jobs/src/core/__tests__/Scheduler.test.js new file mode 100644 index 000000000000..b46bd9d48ac3 --- /dev/null +++ b/packages/jobs/src/core/__tests__/Scheduler.test.js @@ -0,0 +1,25 @@ +import { describe, expect, vi, it, beforeEach } from 'vitest' + +import { Scheduler } from '../Scheduler' + +import { mockAdapter, mockLogger } from './mocks' + +describe('constructor', () => { + it('saves adapter', () => { + const scheduler = new Scheduler({ + adapter: mockAdapter, + logger: mockLogger, + }) + + expect(scheduler.adapter).toEqual(mockAdapter) + }) + + it('saves logger', () => { + const scheduler = new Scheduler({ + adapter: mockAdapter, + logger: mockLogger, + }) + + expect(scheduler.logger).toEqual(mockLogger) + }) +}) From a23c9751d872bc86bb1458b5065371096e426290 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Sat, 10 Aug 2024 21:12:00 +0100 Subject: [PATCH 177/258] remove fast-glob dep --- packages/jobs/package.json | 3 --- yarn.lock | 1 - 2 files changed, 4 deletions(-) diff --git a/packages/jobs/package.json b/packages/jobs/package.json index 595a1534e018..2d9e2c02eac8 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -26,9 +26,6 @@ "test:types": "tstyche", "test:watch": "vitest" }, - "dependencies": { - "fast-glob": "3.3.2" - }, "devDependencies": { "@redwoodjs/project-config": "workspace:*", "tstyche": "2.1.0", diff --git a/yarn.lock b/yarn.lock index 994bff227352..54bbbaff81ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8318,7 +8318,6 @@ __metadata: resolution: "@redwoodjs/jobs@workspace:packages/jobs" dependencies: "@redwoodjs/project-config": "workspace:*" - fast-glob: "npm:3.3.2" tstyche: "npm:2.1.0" tsx: "npm:4.16.2" typescript: "npm:5.5.4" From 5469738f69b3df8d72c0bdf9f2de591e9317f335 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Sat, 10 Aug 2024 21:29:36 +0100 Subject: [PATCH 178/258] add required package during setup --- .../cli/src/commands/setup/jobs/jobsHandler.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/setup/jobs/jobsHandler.js b/packages/cli/src/commands/setup/jobs/jobsHandler.js index dc1569f6ed4f..6e5254141642 100644 --- a/packages/cli/src/commands/setup/jobs/jobsHandler.js +++ b/packages/cli/src/commands/setup/jobs/jobsHandler.js @@ -4,6 +4,8 @@ import * as path from 'node:path' import { getDMMF } from '@prisma/internals' import { Listr } from 'listr2' +import { addApiPackages } from '@redwoodjs/cli-helpers' + import { getPaths, transformTSToJS, writeFile } from '../../../lib' import c from '../../../lib/colors' import { isTypeScriptProject } from '../../../lib/project' @@ -31,7 +33,8 @@ const getModelNames = async () => { return schema.datamodel.models.map((model) => model.name) } -const addModel = () => { +// TODO(jgmw): This won't handle prisma with schema folder preview feature +const addDatabaseModel = () => { const schema = fs.readFileSync(getPaths().api.dbSchema, 'utf-8') const schemaWithUser = schema + MODEL_SCHEMA @@ -42,12 +45,18 @@ const addModel = () => { const tasks = async ({ force }) => { const modelExists = (await getModelNames()).includes('BackgroundJob') + const redwoodVersion = + require(path.join(getPaths().base, 'package.json')).devDependencies[ + '@redwoodjs/core' + ] ?? 'latest' + const jobsPackage = `@redwoodjs/jobs@${redwoodVersion}` + return new Listr( [ { - title: 'Creating job model...', + title: 'Creating job database model...', task: () => { - addModel() + addDatabaseModel() }, skip: () => { if (modelExists) { @@ -117,7 +126,7 @@ const tasks = async ({ force }) => { } }, }, - + addApiPackages([jobsPackage]), { title: 'One more thing...', task: (_ctx, task) => { From 1a3c46c7095965d4b0ce50d352f3cb206c8341dd Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Sat, 10 Aug 2024 21:49:34 +0100 Subject: [PATCH 179/258] update job generator --- packages/cli/src/commands/generate/job/job.js | 55 ++++++------------- .../generate/job/templates/job.ts.template | 9 +-- .../generate/job/templates/test.ts.template | 4 +- 3 files changed, 23 insertions(+), 45 deletions(-) diff --git a/packages/cli/src/commands/generate/job/job.js b/packages/cli/src/commands/generate/job/job.js index 8cd736a313f2..44c411173e0f 100644 --- a/packages/cli/src/commands/generate/job/job.js +++ b/packages/cli/src/commands/generate/job/job.js @@ -1,5 +1,5 @@ -import fs from 'node:fs' import path from 'node:path' +import { pathToFileURL } from 'node:url' import * as changeCase from 'change-case' import execa from 'execa' @@ -9,12 +9,7 @@ import terminalLink from 'terminal-link' import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' import { errorTelemetry } from '@redwoodjs/telemetry' -import { - getPaths, - prettify, - transformTSToJS, - writeFilesTask, -} from '../../../lib' +import { getPaths, transformTSToJS, writeFilesTask } from '../../../lib' import c from '../../../lib/colors' import { isTypeScriptProject } from '../../../lib/project' import { prepareForRollback } from '../../../lib/rollback' @@ -29,6 +24,7 @@ const normalizeName = (name) => { export const files = async ({ name, + queueName, typescript: generateTypescript, tests: generateTests = true, ...rest @@ -46,7 +42,7 @@ export const files = async ({ apiPathSection: 'jobs', generator: 'job', templatePath: 'job.ts.template', - templateVars: { name: jobName, ...rest }, + templateVars: { name: jobName, queueName, ...rest }, outputPath: path.join( getPaths().api.jobs, `${jobName}Job`, @@ -158,46 +154,29 @@ export const handler = async ({ name, force, ...rest }) => { validateName(name) - const jobName = normalizeName(name) - const newJobExport = `${changeCase.camelCase(jobName)}: new ${jobName}Job()` + let queueName = 'default' + + // TODO(jgmw): It would be better if imported the src version so we could "hit" + // on the queue name more often + // Attempt to read the first queue in the users job config file + try { + const jobsManagerFile = getPaths().api.distJobsConfig + const jobManager = await import(pathToFileURL(jobsManagerFile).href) + queueName = jobManager.jobs?.queues[0] ?? 'default' + } catch (_e) { + // We don't care if this fails because we'll fall back to 'default' + } const tasks = new Listr( [ { title: 'Generating job files...', task: async () => { - return writeFilesTask(await files({ name, ...rest }), { + return writeFilesTask(await files({ name, queueName, ...rest }), { overwriteExisting: force, }) }, }, - { - title: 'Adding to api/src/lib/jobs export...', - task: async () => { - const file = fs.readFileSync(getPaths().api.jobsConfig).toString() - const newFile = file - .replace( - /^(export const jobs = \{)(.*)$/m, - `$1\n ${newJobExport},$2`, - ) - .replace(/,\}/, ',\n}') - .replace( - /(import \{ db \} from 'src\/lib\/db')/, - `import ${jobName}Job from 'src/jobs/${jobName}Job'\n$1`, - ) - - fs.writeFileSync( - getPaths().api.jobsConfig, - await prettify(getPaths().api.jobsConfig, newFile), - ) - }, - skip: () => { - const file = fs.readFileSync(getPaths().api.jobsConfig).toString() - if (!file || !file.match(/^export const jobs = \{/m)) { - return '`jobs` export not found, skipping' - } - }, - }, { title: 'Cleaning up...', task: () => { diff --git a/packages/cli/src/commands/generate/job/templates/job.ts.template b/packages/cli/src/commands/generate/job/templates/job.ts.template index 047750694324..d967aa4f1116 100644 --- a/packages/cli/src/commands/generate/job/templates/job.ts.template +++ b/packages/cli/src/commands/generate/job/templates/job.ts.template @@ -1,7 +1,8 @@ -import { RedwoodJob } from '@redwoodjs/jobs' +import { jobs } from 'src/lib/jobs' -export class ${name}Job extends RedwoodJob { - async perform() { +export const ${name}Job = jobs.createJob({ + queue: '${queueName}', + perform: async () => { // job implementation here } -} +}) diff --git a/packages/cli/src/commands/generate/job/templates/test.ts.template b/packages/cli/src/commands/generate/job/templates/test.ts.template index 7dc44353b3a3..554fd30f38e9 100644 --- a/packages/cli/src/commands/generate/job/templates/test.ts.template +++ b/packages/cli/src/commands/generate/job/templates/test.ts.template @@ -2,8 +2,6 @@ import { ${name}Job } from './${name}Job' describe('${name}', () => { it('should not throw any errors', async () => { - const job = new ${name}Job() - - await expect(job.perform()).resolves.not.toThrow() + await expect(${name}Job.perform()).resolves.not.toThrow() }) }) From 002fc8c3513b7d3eb07e27a309ac6e565c9527d7 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Sat, 10 Aug 2024 22:38:07 +0100 Subject: [PATCH 180/258] cast to allow successful build --- packages/jobs/src/core/Worker.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/jobs/src/core/Worker.ts b/packages/jobs/src/core/Worker.ts index ac1af3e2de2f..193ff9169e96 100644 --- a/packages/jobs/src/core/Worker.ts +++ b/packages/jobs/src/core/Worker.ts @@ -78,7 +78,8 @@ export class Worker { lastCheckTime: Date constructor(options: WorkerOptions) { - this.options = { ...DEFAULT_OPTIONS, ...options } + // TODO(jgmw) + this.options = { ...DEFAULT_OPTIONS, ...options } as CompleteOptions if (!options?.adapter) { throw new AdapterRequiredError() From ceba0a78d75687b5e83ff8543079a81a38e0b5f0 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Sat, 10 Aug 2024 22:39:28 +0100 Subject: [PATCH 181/258] move and minor refactor BaseAdapter --- packages/jobs/src/adapters/BaseAdapter.ts | 104 ---------------- .../src/adapters/BaseAdapter/BaseAdapter.ts | 112 ++++++++++++++++++ .../__tests__/BaseAdapter.test.ts | 2 +- 3 files changed, 113 insertions(+), 105 deletions(-) delete mode 100644 packages/jobs/src/adapters/BaseAdapter.ts create mode 100644 packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts rename packages/jobs/src/adapters/{ => BaseAdapter}/__tests__/BaseAdapter.test.ts (97%) diff --git a/packages/jobs/src/adapters/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter.ts deleted file mode 100644 index 140958f68b9f..000000000000 --- a/packages/jobs/src/adapters/BaseAdapter.ts +++ /dev/null @@ -1,104 +0,0 @@ -// Base class for all job adapters. Provides a common interface for scheduling -// jobs. At a minimum, you must implement the `schedule` method in your adapter. -// -// Any object passed to the constructor is saved in `this.options` and should -// be used to configure your custom adapter. If `options.logger` is included -// you can access it via `this.logger` - -import console from 'node:console' - -import type { BasicLogger } from '../types' - -// Arguments sent to an adapter to schedule a job -export interface SchedulePayload { - job: string - args: unknown[] - runAt: Date - queue: string - priority: number -} - -// Arguments returned from an adapter when a job is found. This is the absolute -// minimum interface that's needed for the Executor to invoke the job, but any -// adapter will likely return more info, like the number of previous tries, so -// that it can reschedule the job to run in the future. -export interface BaseJob { - name: string - path: string - args: unknown[] - attempts: number -} - -export interface FindArgs { - processName: string - maxRuntime: number - queues: string[] -} - -export interface BaseAdapterOptions { - logger?: BasicLogger -} - -export interface SuccessOptions { - deleteSuccessfulJobs?: boolean -} - -export interface FailureOptions { - deleteFailedJobs?: boolean -} - -export abstract class BaseAdapter< - TOptions extends BaseAdapterOptions = BaseAdapterOptions, -> { - options: TOptions - logger: BasicLogger - - constructor(options: TOptions) { - this.options = options - this.logger = options?.logger || console - } - - // It's up to the subclass to decide what to return for these functions. - // The job engine itself doesn't care about the return value, but the user may - // want to do something with the result depending on the adapter type, so make - // it `any` to allow for the subclass to return whatever it wants. - - abstract schedule({ - job, - args, - runAt, - queue, - priority, - }: SchedulePayload): void - - // Find a single job that's elegible to run with the given args - abstract find({ - processName, - maxRuntime, - queues, - }: FindArgs): BaseJob | null | Promise - - // Job succeeded - abstract success({ - job, - deleteJob, - }: { - job: BaseJob - deleteJob: boolean - }): void - - // Job errored - abstract error({ job, error }: { job: BaseJob; error: Error }): void - - // Job errored more than maxAttempts, now a permanent failure - abstract failure({ - job, - deleteJob, - }: { - job: BaseJob - deleteJob: boolean - }): void - - // Remove all jobs from storage - abstract clear(): void -} diff --git a/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts new file mode 100644 index 000000000000..30bcfe3107da --- /dev/null +++ b/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts @@ -0,0 +1,112 @@ +import console from 'node:console' + +import type { BasicLogger } from '../../types' + +// Arguments sent to an adapter to schedule a job +export interface SchedulePayload { + job: string + args: unknown[] + runAt: Date + queue: string + priority: number +} + +// Arguments returned from an adapter when a job is found. This is the absolute +// minimum interface that's needed for the Executor to invoke the job, but any +// adapter will likely return more info, like the number of previous tries, so +// that it can reschedule the job to run in the future. +export interface BaseJob { + name: string + path: string + args: unknown[] + attempts: number +} +export type PossibleBaseJob = BaseJob | undefined + +export interface FindArgs { + processName: string + maxRuntime: number + queues: string[] +} + +export interface BaseAdapterOptions { + logger?: BasicLogger +} + +export interface SuccessOptions { + job: TJob + deleteJob?: boolean +} + +export interface ErrorOptions { + job: TJob + error: Error +} + +export interface FailureOptions { + job: TJob + deleteJob?: boolean +} + +/** + * Base class for all job adapters. Provides a common interface for scheduling + * jobs. At a minimum, you must implement the `schedule` method in your adapter. + * + * Any object passed to the constructor is saved in `this.options` and should + * be used to configure your custom adapter. If `options.logger` is included + * you can access it via `this.logger` + */ +export abstract class BaseAdapter< + TOptions extends BaseAdapterOptions = BaseAdapterOptions, + TScheduleReturn = void | Promise, +> { + options: TOptions + logger: NonNullable + + constructor(options: TOptions) { + this.options = options + this.logger = options?.logger ?? console + } + + // It's up to the subclass to decide what to return for these functions. + // The job engine itself doesn't care about the return value, but the user may + // want to do something with the result depending on the adapter type, so make + // it `any` to allow for the subclass to return whatever it wants. + + abstract schedule({ + job, + args, + runAt, + queue, + priority, + }: SchedulePayload): TScheduleReturn + + /** + * Find a single job that's eligible to run with the given args + */ + abstract find({ + processName, + maxRuntime, + queues, + }: FindArgs): PossibleBaseJob | Promise + + /** + * Called when a job has successfully completed + */ + abstract success({ job, deleteJob }: SuccessOptions): void | Promise + + /** + * Called when an attempt to run a job produced an error + */ + abstract error({ job, error }: ErrorOptions): void | Promise + + /** + * Called when a job has errored more than maxAttempts and will not be retried + */ + abstract failure({ job, deleteJob }: FailureOptions): void | Promise + + /** + * Clear all jobs from storage + */ + abstract clear(): void | Promise +} diff --git a/packages/jobs/src/adapters/__tests__/BaseAdapter.test.ts b/packages/jobs/src/adapters/BaseAdapter/__tests__/BaseAdapter.test.ts similarity index 97% rename from packages/jobs/src/adapters/__tests__/BaseAdapter.test.ts rename to packages/jobs/src/adapters/BaseAdapter/__tests__/BaseAdapter.test.ts index 46f5bccfda24..9d573b79fb10 100644 --- a/packages/jobs/src/adapters/__tests__/BaseAdapter.test.ts +++ b/packages/jobs/src/adapters/BaseAdapter/__tests__/BaseAdapter.test.ts @@ -18,7 +18,7 @@ interface TestAdapterOptions extends BaseAdapterOptions { class TestAdapter extends BaseAdapter { schedule() {} find() { - return null + return undefined } success() {} error() {} From 32a87179b995d492da52bf0efa5555445b227f35 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Sat, 10 Aug 2024 22:40:15 +0100 Subject: [PATCH 182/258] move and minor refactor PrismaAdapter --- .../{ => PrismaAdapter}/PrismaAdapter.ts | 101 ++++++++++-------- .../__tests__/PrismaAdapter.test.ts | 6 +- 2 files changed, 58 insertions(+), 49 deletions(-) rename packages/jobs/src/adapters/{ => PrismaAdapter}/PrismaAdapter.ts (76%) rename packages/jobs/src/adapters/{ => PrismaAdapter}/__tests__/PrismaAdapter.test.ts (98%) diff --git a/packages/jobs/src/adapters/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts similarity index 76% rename from packages/jobs/src/adapters/PrismaAdapter.ts rename to packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts index d3999613e579..06c76804cb62 100644 --- a/packages/jobs/src/adapters/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts @@ -1,36 +1,20 @@ -// Implements a job adapter using Prisma ORM. Assumes a table exists with the -// following schema (the table name can be customized): -// -// model BackgroundJob { -// id Int @id @default(autoincrement()) -// attempts Int @default(0) -// handler String -// queue String -// priority Int -// runAt DateTime -// lockedAt DateTime? -// lockedBy String? -// lastError String? -// failedAt DateTime? -// createdAt DateTime @default(now()) -// updatedAt DateTime @updatedAt -// } - import type { PrismaClient } from '@prisma/client' import { camelCase } from 'change-case' -import { DEFAULT_MAX_RUNTIME, DEFAULT_MODEL_NAME } from '../consts' -import { ModelNameError } from '../errors' - +import { DEFAULT_MAX_RUNTIME, DEFAULT_MODEL_NAME } from '../../consts' +import { ModelNameError } from '../../errors' import type { BaseJob, BaseAdapterOptions, - FindArgs, SchedulePayload, -} from './BaseAdapter' -import { BaseAdapter } from './BaseAdapter' + FindArgs, + SuccessOptions, + ErrorOptions, + FailureOptions, +} from '../BaseAdapter/BaseAdapter' +import { BaseAdapter } from '../BaseAdapter/BaseAdapter' -interface PrismaJob extends BaseJob { +export interface PrismaJob extends BaseJob { id: number handler: string runAt: Date @@ -42,11 +26,12 @@ interface PrismaJob extends BaseJob { updatedAt: Date } -interface PrismaAdapterOptions extends BaseAdapterOptions { +export interface PrismaAdapterOptions extends BaseAdapterOptions { /** * An instance of PrismaClient which will be used to talk to the database */ db: PrismaClient + /** * The name of the model in the Prisma schema that represents the job table. * @default 'BackgroundJob' @@ -54,12 +39,13 @@ interface PrismaAdapterOptions extends BaseAdapterOptions { model?: string } -interface SuccessData { - lockedAt: null - lockedBy: null - lastError: null - runAt: null -} +// TODO(jgmw) +// interface SuccessData { +// lockedAt: null +// lockedBy: null +// lastError: null +// runAt: null +// } interface FailureData { lockedAt: null @@ -69,6 +55,27 @@ interface FailureData { runAt: Date | null } +/** + * Implements a job adapter using Prisma ORM. + * + * Assumes a table exists with the following schema (the table name can be customized): + * ```prisma + * model BackgroundJob { + * id Int \@id \@default(autoincrement()) + * attempts Int \@default(0) + * handler String + * queue String + * priority Int + * runAt DateTime + * lockedAt DateTime? + * lockedBy String? + * lastError String? + * failedAt DateTime? + * createdAt DateTime \@default(now()) + * updatedAt DateTime \@updatedAt + * } + * ``` + */ export class PrismaAdapter extends BaseAdapter { db: PrismaClient model: string @@ -96,18 +103,20 @@ export class PrismaAdapter extends BaseAdapter { } } - // Finds the next job to run, locking it so that no other process can pick it - // The act of locking a job is dependant on the DB server, so we'll run some - // raw SQL to do it in each case—Prisma doesn't provide enough flexibility - // in their generated code to do this in a DB-agnostic way. - // - // TODO: there may be more optimized versions of the locking queries in - // Postgres and MySQL + /** + * Finds the next job to run, locking it so that no other process can pick it + * The act of locking a job is dependant on the DB server, so we'll run some + * raw SQL to do it in each case—Prisma doesn't provide enough flexibility + * in their generated code to do this in a DB-agnostic way. + * + * TODO: there may be more optimized versions of the locking queries in + * Postgres and MySQL + */ override async find({ processName, maxRuntime, queues, - }: FindArgs): Promise { + }: FindArgs): Promise { const maxRuntimeExpire = new Date( new Date().getTime() + (maxRuntime || DEFAULT_MAX_RUNTIME * 1000), ) @@ -191,14 +200,16 @@ export class PrismaAdapter extends BaseAdapter { // If we get here then there were either no jobs, or the one we found // was locked by another worker - return null + return undefined } + // TODO(jgmw): This comment doesn't seem to link with the implementation below + // Prisma queries are lazily evaluated and only sent to the db when they are // awaited, so do the await here to ensure they actually run. Otherwise the // user must always await `performLater()` or the job won't actually be // scheduled. - override success({ job, deleteJob }: { job: PrismaJob; deleteJob: boolean }) { + override success({ job, deleteJob }: SuccessOptions) { this.logger.debug(`Job ${job.id} success`) if (deleteJob) { @@ -216,7 +227,7 @@ export class PrismaAdapter extends BaseAdapter { } } - override error({ job, error }: { job: PrismaJob; error: Error }) { + override error({ job, error }: ErrorOptions) { this.logger.debug(`Job ${job.id} failure`) const data: FailureData = { @@ -236,8 +247,8 @@ export class PrismaAdapter extends BaseAdapter { }) } - // Job has had too many attempts, it is not permanently failed. - override failure({ job, deleteJob }: { job: PrismaJob; deleteJob: boolean }) { + // Job has had too many attempts, it has now permanently failed. + override failure({ job, deleteJob }: FailureOptions) { if (deleteJob) { this.accessor.delete({ where: { id: job.id } }) } else { diff --git a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts b/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts similarity index 98% rename from packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts rename to packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts index e4dd71a637ca..e040a4f423ee 100644 --- a/packages/jobs/src/adapters/__tests__/PrismaAdapter.test.ts +++ b/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts @@ -1,10 +1,8 @@ import type { PrismaClient } from '@prisma/client' import { describe, expect, vi, it, beforeEach, afterEach } from 'vitest' -import type CliHelpers from '@redwoodjs/cli-helpers' - -import { DEFAULT_MODEL_NAME } from '../../consts' -import * as errors from '../../errors' +import { DEFAULT_MODEL_NAME } from '../../../consts' +import * as errors from '../../../errors' import { PrismaAdapter } from '../PrismaAdapter' vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) From 3723f3af6213744998ffe8a0de7ea5e757974a12 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Sat, 10 Aug 2024 22:44:07 +0100 Subject: [PATCH 183/258] move prisma specific error --- packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts | 3 ++- .../PrismaAdapter/__tests__/PrismaAdapter.test.ts | 2 +- packages/jobs/src/adapters/PrismaAdapter/errors.ts | 8 ++++++++ packages/jobs/src/errors.ts | 7 ------- 4 files changed, 11 insertions(+), 9 deletions(-) create mode 100644 packages/jobs/src/adapters/PrismaAdapter/errors.ts diff --git a/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts index 06c76804cb62..61a4a624d19a 100644 --- a/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts @@ -2,7 +2,6 @@ import type { PrismaClient } from '@prisma/client' import { camelCase } from 'change-case' import { DEFAULT_MAX_RUNTIME, DEFAULT_MODEL_NAME } from '../../consts' -import { ModelNameError } from '../../errors' import type { BaseJob, BaseAdapterOptions, @@ -14,6 +13,8 @@ import type { } from '../BaseAdapter/BaseAdapter' import { BaseAdapter } from '../BaseAdapter/BaseAdapter' +import { ModelNameError } from './errors' + export interface PrismaJob extends BaseJob { id: number handler: string diff --git a/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts b/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts index e040a4f423ee..8f7ee695e636 100644 --- a/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts +++ b/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts @@ -2,7 +2,7 @@ import type { PrismaClient } from '@prisma/client' import { describe, expect, vi, it, beforeEach, afterEach } from 'vitest' import { DEFAULT_MODEL_NAME } from '../../../consts' -import * as errors from '../../../errors' +import * as errors from '../errors' import { PrismaAdapter } from '../PrismaAdapter' vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) diff --git a/packages/jobs/src/adapters/PrismaAdapter/errors.ts b/packages/jobs/src/adapters/PrismaAdapter/errors.ts new file mode 100644 index 000000000000..e6a237b09b8a --- /dev/null +++ b/packages/jobs/src/adapters/PrismaAdapter/errors.ts @@ -0,0 +1,8 @@ +import { RedwoodJobError } from '../../errors' + +// Thrown when a given model name isn't actually available in the PrismaClient +export class ModelNameError extends RedwoodJobError { + constructor(name: string) { + super(`Model \`${name}\` not found in PrismaClient`) + } +} diff --git a/packages/jobs/src/errors.ts b/packages/jobs/src/errors.ts index 9e3829be1253..3a86959f0d4e 100644 --- a/packages/jobs/src/errors.ts +++ b/packages/jobs/src/errors.ts @@ -15,13 +15,6 @@ export class AdapterNotConfiguredError extends RedwoodJobError { } } -// Thrown when a given model name isn't actually available in the PrismaClient -export class ModelNameError extends RedwoodJobError { - constructor(name: string) { - super(`Model \`${name}\` not found in PrismaClient`) - } -} - // Thrown when the Worker or Executor is instantiated without an adapter export class AdapterRequiredError extends RedwoodJobError { constructor() { From 5041998407595dc2a36c6a2c705675175a72c6ff Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Sun, 11 Aug 2024 00:01:16 +0100 Subject: [PATCH 184/258] update executor --- .../src/adapters/BaseAdapter/BaseAdapter.ts | 14 +---- packages/jobs/src/core/Executor.ts | 46 +++++++------- packages/jobs/src/loaders.ts | 60 ++++++++++--------- packages/jobs/src/types.ts | 14 ++++- packages/jobs/src/util.ts | 6 ++ 5 files changed, 72 insertions(+), 68 deletions(-) create mode 100644 packages/jobs/src/util.ts diff --git a/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts index 30bcfe3107da..3aaf06fb1f73 100644 --- a/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts @@ -1,6 +1,6 @@ import console from 'node:console' -import type { BasicLogger } from '../../types' +import type { BaseJob, BasicLogger, PossibleBaseJob } from '../../types' // Arguments sent to an adapter to schedule a job export interface SchedulePayload { @@ -11,18 +11,6 @@ export interface SchedulePayload { priority: number } -// Arguments returned from an adapter when a job is found. This is the absolute -// minimum interface that's needed for the Executor to invoke the job, but any -// adapter will likely return more info, like the number of previous tries, so -// that it can reschedule the job to run in the future. -export interface BaseJob { - name: string - path: string - args: unknown[] - attempts: number -} -export type PossibleBaseJob = BaseJob | undefined - export interface FindArgs { processName: string maxRuntime: number diff --git a/packages/jobs/src/core/Executor.ts b/packages/jobs/src/core/Executor.ts index 54dbf57483ac..eb7c2fa2f5e8 100644 --- a/packages/jobs/src/core/Executor.ts +++ b/packages/jobs/src/core/Executor.ts @@ -2,7 +2,7 @@ import console from 'node:console' -import type { BaseAdapter } from '../adapters/BaseAdapter' +import type { BaseAdapter } from '../adapters/BaseAdapter/BaseAdapter' import { DEFAULT_MAX_ATTEMPTS, DEFAULT_DELETE_FAILED_JOBS, @@ -10,27 +10,18 @@ import { } from '../consts' import { AdapterRequiredError, JobRequiredError } from '../errors' import { loadJob } from '../loaders' -import type { BasicLogger } from '../types' +import type { BaseJob, BasicLogger } from '../types' interface Options { adapter: BaseAdapter - job: any + job: BaseJob logger?: BasicLogger maxAttempts?: number deleteFailedJobs?: boolean deleteSuccessfulJobs?: boolean } -interface DefaultOptions { - logger: BasicLogger - maxAttempts: number - deleteFailedJobs: boolean - deleteSuccessfulJobs: boolean -} - -type CompleteOptions = Options & DefaultOptions - -export const DEFAULTS: DefaultOptions = { +export const DEFAULTS = { logger: console, maxAttempts: DEFAULT_MAX_ATTEMPTS, deleteFailedJobs: DEFAULT_DELETE_FAILED_JOBS, @@ -38,13 +29,13 @@ export const DEFAULTS: DefaultOptions = { } export class Executor { - options: CompleteOptions - adapter: BaseAdapter - logger: BasicLogger - job: any | null - maxAttempts: number - deleteFailedJobs: boolean - deleteSuccessfulJobs: boolean + options: Required + adapter: Options['adapter'] + logger: NonNullable + job: BaseJob + maxAttempts: NonNullable + deleteFailedJobs: NonNullable + deleteSuccessfulJobs: NonNullable constructor(options: Options) { this.options = { ...DEFAULTS, ...options } @@ -65,21 +56,24 @@ export class Executor { this.deleteSuccessfulJobs = this.options.deleteSuccessfulJobs } + get jobId() { + return `${this.job.path}:${this.job.name}` + } + async perform() { - this.logger.info(`Started job ${this.job.id}`) + this.logger.info(`Started job ${this.jobId}`) - // TODO break these lines down into individual try/catch blocks? try { - const job = loadJob({ name: this.job.name, path: this.job.path }) + const job = await loadJob({ name: this.job.name, path: this.job.path }) await job.perform(...this.job.args) - // TODO(@rob): Ask Josh about why this would "have no effect"? await this.adapter.success({ job: this.job, deleteJob: DEFAULT_DELETE_SUCCESSFUL_JOBS, }) } catch (error: any) { - this.logger.error(`Error in job ${this.job.id}: ${error.message}`) + // TODO(jgmw): Handle the error 'any' better + this.logger.error(`Error in job ${this.jobId}: ${error.message}`) this.logger.error(error.stack) await this.adapter.error({ @@ -90,7 +84,7 @@ export class Executor { if (this.job.attempts >= this.maxAttempts) { this.logger.warn( this.job, - `Failed job ${this.job.id}: reached max attempts (${this.maxAttempts})`, + `Failed job ${this.jobId}: reached max attempts (${this.maxAttempts})`, ) await this.adapter.failure({ job: this.job, diff --git a/packages/jobs/src/loaders.ts b/packages/jobs/src/loaders.ts index d3f810c938c7..b79423083f26 100644 --- a/packages/jobs/src/loaders.ts +++ b/packages/jobs/src/loaders.ts @@ -1,53 +1,59 @@ +import fs from 'node:fs' import path from 'node:path' -import { pathToFileURL } from 'node:url' import { getPaths } from '@redwoodjs/project-config' import type { JobManager } from './core/JobManager' import { JobsLibNotFoundError, JobNotFoundError } from './errors' import type { Adapters, BasicLogger, Job } from './types' - -export function makeFilePath(path: string) { - return pathToFileURL(path).href -} - -// Loads the named export from the app's jobs config in api/src/lib/jobs.{js,ts} -// to configure the worker, defaults to `workerConfig` -export const loadJobsManager = (): JobManager< - Adapters, - string[], - BasicLogger +import { makeFilePath } from './util' + +/** + * Loads the job manager from the users project + * + * @returns JobManager + */ +export const loadJobsManager = async (): Promise< + JobManager > => { + // Confirm the specific lib/jobs.ts file exists const jobsConfigPath = getPaths().api.distJobsConfig + if (!jobsConfigPath) { + throw new JobsLibNotFoundError() + } - if (jobsConfigPath) { - return require(jobsConfigPath).jobs - } else { + // Import the jobs manager + const importPath = makeFilePath(jobsConfigPath) + const { jobs } = await import(importPath) + if (!jobs) { throw new JobsLibNotFoundError() } + + return jobs } -// Loads a job from the app's filesystem in api/src/jobs -export const loadJob = ({ +/** + * Load a specific job implementation from the users project + */ +export const loadJob = async ({ name: jobName, path: jobPath, }: { name: string path: string -}): Job => { - const jobsPath = getPaths().api.distJobs - - let job - - try { - job = require(path.join(jobsPath, jobPath)) - } catch (e) { +}): Promise> => { + // Confirm the specific job file exists + const completeJobPath = path.join(getPaths().api.distJobs, jobPath) + '.js' + const importPath = makeFilePath(completeJobPath) + if (!fs.existsSync(importPath)) { throw new JobNotFoundError(jobName) } - if (!job[jobName]) { + // Import the job + const jobModule = await import(importPath) + if (!jobModule[jobName]) { throw new JobNotFoundError(jobName) } - return job[jobName] + return jobModule[jobName] } diff --git a/packages/jobs/src/types.ts b/packages/jobs/src/types.ts index 3127413762cc..469ebf617acb 100644 --- a/packages/jobs/src/types.ts +++ b/packages/jobs/src/types.ts @@ -2,9 +2,9 @@ // debug messages. RedwoodJob will fallback to use `console` if no // logger is passed in to RedwoodJob or any adapter. Luckily both Redwood's -import type { BaseAdapter } from './adapters/BaseAdapter' +import type { BaseAdapter } from './adapters/BaseAdapter/BaseAdapter' -// Logger and the standard console logger conform to this shape. +// Redwood's logger and the standard console logger conform to this shape. export interface BasicLogger { debug: (message?: any, ...optionalParams: any[]) => void info: (message?: any, ...optionalParams: any[]) => void @@ -12,6 +12,16 @@ export interface BasicLogger { error: (message?: any, ...optionalParams: any[]) => void } +// This is the minimum interface that a "job" must conform to in order to be +// scheduled and executed by Redwood's job engine. +export interface BaseJob { + name: string + path: string + args: unknown[] + attempts: number +} +export type PossibleBaseJob = BaseJob | undefined + export type Adapters = Record export interface WorkerConfig { diff --git a/packages/jobs/src/util.ts b/packages/jobs/src/util.ts new file mode 100644 index 000000000000..e8fb0fba2f9b --- /dev/null +++ b/packages/jobs/src/util.ts @@ -0,0 +1,6 @@ +import { pathToFileURL } from 'node:url' + +// TODO(jgmw): Refactor and move this into `@redwoodjs/project-config` or similar +export function makeFilePath(path: string) { + return pathToFileURL(path).href +} From 636c22c88cb441999363bf95aa8fad172ac53c59 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Sun, 11 Aug 2024 00:21:22 +0100 Subject: [PATCH 185/258] fix jobs having name and path --- .../src/adapters/BaseAdapter/BaseAdapter.ts | 23 ++++++------------- .../adapters/PrismaAdapter/PrismaAdapter.ts | 13 ++++++++--- packages/jobs/src/core/JobManager.ts | 5 +++- packages/jobs/src/core/Scheduler.ts | 18 ++++++++++----- packages/jobs/src/core/Worker.ts | 2 +- packages/jobs/src/index.ts | 5 ---- packages/jobs/src/loaders.ts | 7 ++---- packages/jobs/src/types.ts | 14 ++++++++++- 8 files changed, 49 insertions(+), 38 deletions(-) diff --git a/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts index 3aaf06fb1f73..b1023b7c466d 100644 --- a/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts @@ -4,7 +4,8 @@ import type { BaseJob, BasicLogger, PossibleBaseJob } from '../../types' // Arguments sent to an adapter to schedule a job export interface SchedulePayload { - job: string + name: string + path: string args: unknown[] runAt: Date queue: string @@ -61,37 +62,27 @@ export abstract class BaseAdapter< // want to do something with the result depending on the adapter type, so make // it `any` to allow for the subclass to return whatever it wants. - abstract schedule({ - job, - args, - runAt, - queue, - priority, - }: SchedulePayload): TScheduleReturn + abstract schedule(payload: SchedulePayload): TScheduleReturn /** * Find a single job that's eligible to run with the given args */ - abstract find({ - processName, - maxRuntime, - queues, - }: FindArgs): PossibleBaseJob | Promise + abstract find(args: FindArgs): PossibleBaseJob | Promise /** * Called when a job has successfully completed */ - abstract success({ job, deleteJob }: SuccessOptions): void | Promise + abstract success(options: SuccessOptions): void | Promise /** * Called when an attempt to run a job produced an error */ - abstract error({ job, error }: ErrorOptions): void | Promise + abstract error(options: ErrorOptions): void | Promise /** * Called when a job has errored more than maxAttempts and will not be retried */ - abstract failure({ job, deleteJob }: FailureOptions): void | Promise + abstract failure(options: FailureOptions): void | Promise /** * Clear all jobs from storage diff --git a/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts index 61a4a624d19a..49d4b4f78979 100644 --- a/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts @@ -2,8 +2,8 @@ import type { PrismaClient } from '@prisma/client' import { camelCase } from 'change-case' import { DEFAULT_MAX_RUNTIME, DEFAULT_MODEL_NAME } from '../../consts' +import type { BaseJob } from '../../types' import type { - BaseJob, BaseAdapterOptions, SchedulePayload, FindArgs, @@ -261,10 +261,17 @@ export class PrismaAdapter extends BaseAdapter { } // Schedules a job by creating a new record in the background job table - override schedule({ job, args, runAt, queue, priority }: SchedulePayload) { + override schedule({ + name, + path, + args, + runAt, + queue, + priority, + }: SchedulePayload) { this.accessor.create({ data: { - handler: JSON.stringify({ job, args }), + handler: JSON.stringify({ name, path, args }), runAt, queue, priority, diff --git a/packages/jobs/src/core/JobManager.ts b/packages/jobs/src/core/JobManager.ts index b1d83ec45c34..f8aade02f1be 100644 --- a/packages/jobs/src/core/JobManager.ts +++ b/packages/jobs/src/core/JobManager.ts @@ -46,7 +46,10 @@ export class JobManager< createJob( jobDefinition: JobDefinition, ): Job { - return jobDefinition + // The cast is necessary because the JobDefinition type lacks the `name` and + // `path` properties that are required by the Job type. These properties are + // added to the job at build time by a plugin in the build process. + return jobDefinition as Job } createWorker() { diff --git a/packages/jobs/src/core/Scheduler.ts b/packages/jobs/src/core/Scheduler.ts index 988f2177519c..6d0897d25a3e 100644 --- a/packages/jobs/src/core/Scheduler.ts +++ b/packages/jobs/src/core/Scheduler.ts @@ -1,4 +1,7 @@ -import type { BaseAdapter, SchedulePayload } from '../adapters/BaseAdapter' +import type { + BaseAdapter, + SchedulePayload, +} from '../adapters/BaseAdapter/BaseAdapter' import { DEFAULT_LOGGER, DEFAULT_PRIORITY, @@ -19,9 +22,10 @@ interface SchedulerConfig { export class Scheduler { adapter: TAdapter - logger: BasicLogger + logger: NonNullable['logger']> constructor({ adapter, logger }: SchedulerConfig) { + // TODO(jgmw): Confirm everywhere else uses this DEFAULT_LOGGER this.logger = logger ?? DEFAULT_LOGGER this.adapter = adapter @@ -32,7 +36,7 @@ export class Scheduler { computeRunAt(wait: number, waitUntil: Date | null) { if (wait && wait > 0) { - return new Date(new Date().getTime() + wait * 1000) + return new Date(Date.now() + wait * 1000) } else if (waitUntil) { return waitUntil } else { @@ -55,8 +59,8 @@ export class Scheduler { } return { - // @ts-expect-error(jgmw): Fix this - job: job.name as string, + name: job.name, + path: job.path, args: args ?? [], runAt: this.computeRunAt(wait, waitUntil), queue: queue, @@ -75,8 +79,10 @@ export class Scheduler { }) { const payload = this.buildPayload(job, jobArgs, jobOptions) + // TODO(jgmw): Ask Rob about this [RedwoodJob] prefix, consistent usage in worker, executor, etc? this.logger.info( payload, + // TODO(jgmw): Ask Rob what this prints out? `[RedwoodJob] Scheduling ${this.constructor.name}`, ) @@ -85,7 +91,7 @@ export class Scheduler { return true } catch (e) { throw new SchedulingError( - `[RedwoodJob] Exception when scheduling ${payload.job}`, + `[RedwoodJob] Exception when scheduling ${payload.name}`, e as Error, ) } diff --git a/packages/jobs/src/core/Worker.ts b/packages/jobs/src/core/Worker.ts index 193ff9169e96..e0a27a2223f5 100644 --- a/packages/jobs/src/core/Worker.ts +++ b/packages/jobs/src/core/Worker.ts @@ -2,7 +2,7 @@ import { setTimeout } from 'node:timers' -import type { BaseAdapter } from '../adapters/BaseAdapter' +import type { BaseAdapter } from '../adapters/BaseAdapter/BaseAdapter' import { DEFAULT_DELETE_FAILED_JOBS, DEFAULT_DELETE_SUCCESSFUL_JOBS, diff --git a/packages/jobs/src/index.ts b/packages/jobs/src/index.ts index 1db63e28895d..e4afd126753b 100644 --- a/packages/jobs/src/index.ts +++ b/packages/jobs/src/index.ts @@ -3,8 +3,3 @@ export * from './errors' export { JobManager } from './core/JobManager' export { Executor } from './core/Executor' export { Worker } from './core/Worker' - -export { BaseAdapter } from './adapters/BaseAdapter' -export { PrismaAdapter } from './adapters/PrismaAdapter' - -// TODO(jgmw): We tend to avoid wanting to barrel export everything diff --git a/packages/jobs/src/loaders.ts b/packages/jobs/src/loaders.ts index b79423083f26..574e4873b5d1 100644 --- a/packages/jobs/src/loaders.ts +++ b/packages/jobs/src/loaders.ts @@ -5,7 +5,7 @@ import { getPaths } from '@redwoodjs/project-config' import type { JobManager } from './core/JobManager' import { JobsLibNotFoundError, JobNotFoundError } from './errors' -import type { Adapters, BasicLogger, Job } from './types' +import type { Adapters, BasicLogger, Job, JobComputedProperties } from './types' import { makeFilePath } from './util' /** @@ -38,10 +38,7 @@ export const loadJobsManager = async (): Promise< export const loadJob = async ({ name: jobName, path: jobPath, -}: { - name: string - path: string -}): Promise> => { +}: JobComputedProperties): Promise> => { // Confirm the specific job file exists const completeJobPath = path.join(getPaths().api.distJobs, jobPath) + '.js' const importPath = makeFilePath(completeJobPath) diff --git a/packages/jobs/src/types.ts b/packages/jobs/src/types.ts index 469ebf617acb..126b3a5cc771 100644 --- a/packages/jobs/src/types.ts +++ b/packages/jobs/src/types.ts @@ -157,10 +157,22 @@ export interface JobDefinition< perform: (...args: TArgs) => Promise | void } +export type JobComputedProperties = { + /** + * The name of the job that was defined in the job file. + */ + name: string + + /** + * The path to the job file that this job was defined in. + */ + path: string +} + export type Job< TQueues extends string[], TArgs extends unknown[] = [], -> = JobDefinition +> = JobDefinition & JobComputedProperties export type ScheduleJobOptions = | { From 11bcf95aaef0b637bda56db6db92a36209fd42bf Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Sun, 11 Aug 2024 00:23:47 +0100 Subject: [PATCH 186/258] add await to fix bins --- packages/jobs/src/bins/rw-jobs-worker.ts | 2 +- packages/jobs/src/bins/rw-jobs.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index 1bf24653c369..3e71f1b7034d 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -96,7 +96,7 @@ const main = async () => { let jobsConfig try { - jobsConfig = loadJobsManager() + jobsConfig = await loadJobsManager() } catch (e) { console.error(e) process.exit(1) diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index 9de891425612..3f21aa3dea85 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -247,7 +247,7 @@ const main = async () => { let jobsConfig try { - jobsConfig = loadJobsManager() + jobsConfig = await loadJobsManager() } catch (e) { console.error(e) process.exit(1) From 14d5c87d66c4b5cc475f96bd7dbbeb09d7419494 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Sun, 11 Aug 2024 00:27:22 +0100 Subject: [PATCH 187/258] fix prisma adapter tests --- .../jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts | 2 +- .../PrismaAdapter/__tests__/PrismaAdapter.test.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts index 49d4b4f78979..8581cfbe16a5 100644 --- a/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts @@ -261,7 +261,7 @@ export class PrismaAdapter extends BaseAdapter { } // Schedules a job by creating a new record in the background job table - override schedule({ + override async schedule({ name, path, args, diff --git a/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts b/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts index 8f7ee695e636..3a478901349d 100644 --- a/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts +++ b/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts @@ -92,7 +92,8 @@ describe('schedule()', () => { .mockReturnValue({ id: 1 }) const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) await adapter.schedule({ - job: 'RedwoodJob', + name: 'RedwoodJob', + path: 'RedwoodJob/RedwoodJob', args: ['foo', 'bar'], queue: 'default', priority: 50, @@ -102,7 +103,8 @@ describe('schedule()', () => { expect(createSpy).toHaveBeenCalledWith({ data: { handler: JSON.stringify({ - job: 'RedwoodJob', + name: 'RedwoodJob', + path: 'RedwoodJob/RedwoodJob', args: ['foo', 'bar'], }), priority: 50, @@ -114,7 +116,7 @@ describe('schedule()', () => { }) describe('find()', () => { - it('returns null if no job found', async () => { + it('returns undefined if no job found', async () => { vi.spyOn(mockDb.backgroundJob, 'findFirst').mockReturnValue(null) const adapter = new PrismaAdapter({ db: mockDb, logger: mockLogger }) const job = await adapter.find({ @@ -123,7 +125,7 @@ describe('find()', () => { queues: ['foobar'], }) - expect(job).toBeNull() + expect(job).toBeUndefined() }) it('returns a job if found', async () => { From cbdf2d5f14cf7a66f3e144b6041ce746464b0b6f Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Sun, 11 Aug 2024 00:33:04 +0100 Subject: [PATCH 188/258] update comments --- .../jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts | 9 +-------- packages/jobs/src/core/__tests__/Executor.test.js | 1 + 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts index 8581cfbe16a5..f44f1a4c6fc2 100644 --- a/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts @@ -40,14 +40,6 @@ export interface PrismaAdapterOptions extends BaseAdapterOptions { model?: string } -// TODO(jgmw) -// interface SuccessData { -// lockedAt: null -// lockedBy: null -// lastError: null -// runAt: null -// } - interface FailureData { lockedAt: null lockedBy: null @@ -205,6 +197,7 @@ export class PrismaAdapter extends BaseAdapter { } // TODO(jgmw): This comment doesn't seem to link with the implementation below + // TODO(jgmw): I think maybe these all need to be async and awaited? // Prisma queries are lazily evaluated and only sent to the db when they are // awaited, so do the await here to ensure they actually run. Otherwise the diff --git a/packages/jobs/src/core/__tests__/Executor.test.js b/packages/jobs/src/core/__tests__/Executor.test.js index ecda4dc86e4c..1e9947695118 100644 --- a/packages/jobs/src/core/__tests__/Executor.test.js +++ b/packages/jobs/src/core/__tests__/Executor.test.js @@ -73,6 +73,7 @@ describe('perform', () => { vi.resetAllMocks() }) + // TODO(jgmw): I'm not sure I understand the comment below. // TODO once these dynamic imports are converted into loadJob in shared, just mock out the result of loadJob it('invokes the `perform` method on the job class', async () => { const mockAdapter = { success: vi.fn() } From 06978d0f163908322a23f647c975cebfcd40cca9 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Sun, 11 Aug 2024 00:41:31 +0100 Subject: [PATCH 189/258] remove type testing package for now --- packages/jobs/package.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/jobs/package.json b/packages/jobs/package.json index 2d9e2c02eac8..7ed47eb1271f 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -23,12 +23,10 @@ "build:watch": "nodemon --watch src --ext \"js,jsx,ts,tsx\" --ignore dist --exec \"yarn build\"", "prepublishOnly": "NODE_ENV=production yarn build", "test": "vitest run", - "test:types": "tstyche", "test:watch": "vitest" }, "devDependencies": { "@redwoodjs/project-config": "workspace:*", - "tstyche": "2.1.0", "tsx": "4.16.2", "typescript": "5.5.4", "vitest": "2.0.4" From 333f6a203f902a08d18ce725171fbc87458b3dd0 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Sun, 11 Aug 2024 00:43:55 +0100 Subject: [PATCH 190/258] fix deps --- packages/jobs/package.json | 2 +- yarn.lock | 130 ++++++++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/packages/jobs/package.json b/packages/jobs/package.json index 7ed47eb1271f..7d627375b906 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -29,6 +29,6 @@ "@redwoodjs/project-config": "workspace:*", "tsx": "4.16.2", "typescript": "5.5.4", - "vitest": "2.0.4" + "vitest": "2.0.5" } } diff --git a/yarn.lock b/yarn.lock index 2c2326a70322..21c94b94f4bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8271,7 +8271,6 @@ __metadata: resolution: "@redwoodjs/jobs@workspace:packages/jobs" dependencies: "@redwoodjs/project-config": "workspace:*" - tstyche: "npm:2.1.0" tsx: "npm:4.16.2" typescript: "npm:5.5.4" vitest: "npm:2.0.4" @@ -11432,6 +11431,18 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:2.0.4": + version: 2.0.4 + resolution: "@vitest/expect@npm:2.0.4" + dependencies: + "@vitest/spy": "npm:2.0.4" + "@vitest/utils": "npm:2.0.4" + chai: "npm:^5.1.1" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/18acdd6b1f5001830722fab7d41b0bd754e37572dded74d1549c5e8f40e58d9e4bbbb6a8ce6be1200b04653237329ba1aeeb3330c2a41f1024450016464d491e + languageName: node + linkType: hard + "@vitest/expect@npm:2.0.5": version: 2.0.5 resolution: "@vitest/expect@npm:2.0.5" @@ -11444,7 +11455,16 @@ __metadata: languageName: node linkType: hard -"@vitest/pretty-format@npm:2.0.5, @vitest/pretty-format@npm:^2.0.5": +"@vitest/pretty-format@npm:2.0.4": + version: 2.0.4 + resolution: "@vitest/pretty-format@npm:2.0.4" + dependencies: + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/c2ac3ca302b93ad53ea2977209ee4eb31a313c18690034a09f8ec5528d7e82715c233c4927ecf8b364203c5e5475231d9b737b3fb7680eea71882e1eae11e473 + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:2.0.5, @vitest/pretty-format@npm:^2.0.4, @vitest/pretty-format@npm:^2.0.5": version: 2.0.5 resolution: "@vitest/pretty-format@npm:2.0.5" dependencies: @@ -11453,6 +11473,16 @@ __metadata: languageName: node linkType: hard +"@vitest/runner@npm:2.0.4": + version: 2.0.4 + resolution: "@vitest/runner@npm:2.0.4" + dependencies: + "@vitest/utils": "npm:2.0.4" + pathe: "npm:^1.1.2" + checksum: 10c0/b550372ce5e2c6a3f08dbd584ea669723fc0d789ebaa4224b703f12e908813fb76b963ea9ac2265aa751cab0309f637dc1fa7ce3fb3e67e08e52e241d33237ee + languageName: node + linkType: hard + "@vitest/runner@npm:2.0.5": version: 2.0.5 resolution: "@vitest/runner@npm:2.0.5" @@ -11463,6 +11493,17 @@ __metadata: languageName: node linkType: hard +"@vitest/snapshot@npm:2.0.4": + version: 2.0.4 + resolution: "@vitest/snapshot@npm:2.0.4" + dependencies: + "@vitest/pretty-format": "npm:2.0.4" + magic-string: "npm:^0.30.10" + pathe: "npm:^1.1.2" + checksum: 10c0/67608c5b1e2f8b02ebc95286cd644c31ea29344c81d67151375b6eebf088a0eea242756eefb509aac626b8f7f091044fdcbc80d137d811ead1117a4a524e2d74 + languageName: node + linkType: hard + "@vitest/snapshot@npm:2.0.5": version: 2.0.5 resolution: "@vitest/snapshot@npm:2.0.5" @@ -11474,6 +11515,15 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:2.0.4": + version: 2.0.4 + resolution: "@vitest/spy@npm:2.0.4" + dependencies: + tinyspy: "npm:^3.0.0" + checksum: 10c0/ef0d0c5e36bb6dfa3ef7561368b39c92cd89bb52d112ec13345dfc99981796a9af98bafd35ce6952322a6a7534eaad144485fe7764628d94d77edeba5fa773b6 + languageName: node + linkType: hard + "@vitest/spy@npm:2.0.5": version: 2.0.5 resolution: "@vitest/spy@npm:2.0.5" @@ -11483,6 +11533,18 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:2.0.4": + version: 2.0.4 + resolution: "@vitest/utils@npm:2.0.4" + dependencies: + "@vitest/pretty-format": "npm:2.0.4" + estree-walker: "npm:^3.0.3" + loupe: "npm:^3.1.1" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/48e0bad3aa463d147b125e355b6bc6c5b4a5eab600132ebafac8379800273b2f47df17dbf76fe179b1500cc6b5866ead2d375a39a9114a03f705eb8850b93afa + languageName: node + linkType: hard + "@vitest/utils@npm:2.0.5": version: 2.0.5 resolution: "@vitest/utils@npm:2.0.5" @@ -29442,6 +29504,21 @@ __metadata: languageName: node linkType: hard +"vite-node@npm:2.0.4": + version: 2.0.4 + resolution: "vite-node@npm:2.0.4" + dependencies: + cac: "npm:^6.7.14" + debug: "npm:^4.3.5" + pathe: "npm:^1.1.2" + tinyrainbow: "npm:^1.2.0" + vite: "npm:^5.0.0" + bin: + vite-node: vite-node.mjs + checksum: 10c0/2689b05b391b59cf3d15e1e80884e9b054f2ca90b2150cc7a08b0f234e79e6750a28cc8d107a57f005185e759c3bc020030f687065317fc37fe169ce17f4cdb7 + languageName: node + linkType: hard + "vite-node@npm:2.0.5": version: 2.0.5 resolution: "vite-node@npm:2.0.5" @@ -29522,6 +29599,55 @@ __metadata: languageName: node linkType: hard +"vitest@npm:2.0.4": + version: 2.0.4 + resolution: "vitest@npm:2.0.4" + dependencies: + "@ampproject/remapping": "npm:^2.3.0" + "@vitest/expect": "npm:2.0.4" + "@vitest/pretty-format": "npm:^2.0.4" + "@vitest/runner": "npm:2.0.4" + "@vitest/snapshot": "npm:2.0.4" + "@vitest/spy": "npm:2.0.4" + "@vitest/utils": "npm:2.0.4" + chai: "npm:^5.1.1" + debug: "npm:^4.3.5" + execa: "npm:^8.0.1" + magic-string: "npm:^0.30.10" + pathe: "npm:^1.1.2" + std-env: "npm:^3.7.0" + tinybench: "npm:^2.8.0" + tinypool: "npm:^1.0.0" + tinyrainbow: "npm:^1.2.0" + vite: "npm:^5.0.0" + vite-node: "npm:2.0.4" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@types/node": ^18.0.0 || >=20.0.0 + "@vitest/browser": 2.0.4 + "@vitest/ui": 2.0.4 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10c0/139200d0bda3270fd00641e4bd5524f78a2b1fe9a3d4a0d5ba2b6ed08bbcf6f1e711cc4bfd8b0d823628a2fcab00f822bb210bd5bf3c6a9260fd6115ea085a3d + languageName: node + linkType: hard + "vitest@npm:2.0.5": version: 2.0.5 resolution: "vitest@npm:2.0.5" From 505e175b0758c55bb7cab37f83f48a2be040e6d6 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Sun, 11 Aug 2024 00:50:53 +0100 Subject: [PATCH 191/258] actually fix deps --- yarn.lock | 131 +----------------------------------------------------- 1 file changed, 2 insertions(+), 129 deletions(-) diff --git a/yarn.lock b/yarn.lock index 21c94b94f4bb..3d0a72f6a1ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8273,7 +8273,7 @@ __metadata: "@redwoodjs/project-config": "workspace:*" tsx: "npm:4.16.2" typescript: "npm:5.5.4" - vitest: "npm:2.0.4" + vitest: "npm:2.0.5" bin: rw-jobs: ./dist/bins/rw-jobs.js rw-jobs-worker: ./dist/bins/rw-jobs-worker.js @@ -11431,18 +11431,6 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:2.0.4": - version: 2.0.4 - resolution: "@vitest/expect@npm:2.0.4" - dependencies: - "@vitest/spy": "npm:2.0.4" - "@vitest/utils": "npm:2.0.4" - chai: "npm:^5.1.1" - tinyrainbow: "npm:^1.2.0" - checksum: 10c0/18acdd6b1f5001830722fab7d41b0bd754e37572dded74d1549c5e8f40e58d9e4bbbb6a8ce6be1200b04653237329ba1aeeb3330c2a41f1024450016464d491e - languageName: node - linkType: hard - "@vitest/expect@npm:2.0.5": version: 2.0.5 resolution: "@vitest/expect@npm:2.0.5" @@ -11455,16 +11443,7 @@ __metadata: languageName: node linkType: hard -"@vitest/pretty-format@npm:2.0.4": - version: 2.0.4 - resolution: "@vitest/pretty-format@npm:2.0.4" - dependencies: - tinyrainbow: "npm:^1.2.0" - checksum: 10c0/c2ac3ca302b93ad53ea2977209ee4eb31a313c18690034a09f8ec5528d7e82715c233c4927ecf8b364203c5e5475231d9b737b3fb7680eea71882e1eae11e473 - languageName: node - linkType: hard - -"@vitest/pretty-format@npm:2.0.5, @vitest/pretty-format@npm:^2.0.4, @vitest/pretty-format@npm:^2.0.5": +"@vitest/pretty-format@npm:2.0.5, @vitest/pretty-format@npm:^2.0.5": version: 2.0.5 resolution: "@vitest/pretty-format@npm:2.0.5" dependencies: @@ -11473,16 +11452,6 @@ __metadata: languageName: node linkType: hard -"@vitest/runner@npm:2.0.4": - version: 2.0.4 - resolution: "@vitest/runner@npm:2.0.4" - dependencies: - "@vitest/utils": "npm:2.0.4" - pathe: "npm:^1.1.2" - checksum: 10c0/b550372ce5e2c6a3f08dbd584ea669723fc0d789ebaa4224b703f12e908813fb76b963ea9ac2265aa751cab0309f637dc1fa7ce3fb3e67e08e52e241d33237ee - languageName: node - linkType: hard - "@vitest/runner@npm:2.0.5": version: 2.0.5 resolution: "@vitest/runner@npm:2.0.5" @@ -11493,17 +11462,6 @@ __metadata: languageName: node linkType: hard -"@vitest/snapshot@npm:2.0.4": - version: 2.0.4 - resolution: "@vitest/snapshot@npm:2.0.4" - dependencies: - "@vitest/pretty-format": "npm:2.0.4" - magic-string: "npm:^0.30.10" - pathe: "npm:^1.1.2" - checksum: 10c0/67608c5b1e2f8b02ebc95286cd644c31ea29344c81d67151375b6eebf088a0eea242756eefb509aac626b8f7f091044fdcbc80d137d811ead1117a4a524e2d74 - languageName: node - linkType: hard - "@vitest/snapshot@npm:2.0.5": version: 2.0.5 resolution: "@vitest/snapshot@npm:2.0.5" @@ -11515,15 +11473,6 @@ __metadata: languageName: node linkType: hard -"@vitest/spy@npm:2.0.4": - version: 2.0.4 - resolution: "@vitest/spy@npm:2.0.4" - dependencies: - tinyspy: "npm:^3.0.0" - checksum: 10c0/ef0d0c5e36bb6dfa3ef7561368b39c92cd89bb52d112ec13345dfc99981796a9af98bafd35ce6952322a6a7534eaad144485fe7764628d94d77edeba5fa773b6 - languageName: node - linkType: hard - "@vitest/spy@npm:2.0.5": version: 2.0.5 resolution: "@vitest/spy@npm:2.0.5" @@ -11533,18 +11482,6 @@ __metadata: languageName: node linkType: hard -"@vitest/utils@npm:2.0.4": - version: 2.0.4 - resolution: "@vitest/utils@npm:2.0.4" - dependencies: - "@vitest/pretty-format": "npm:2.0.4" - estree-walker: "npm:^3.0.3" - loupe: "npm:^3.1.1" - tinyrainbow: "npm:^1.2.0" - checksum: 10c0/48e0bad3aa463d147b125e355b6bc6c5b4a5eab600132ebafac8379800273b2f47df17dbf76fe179b1500cc6b5866ead2d375a39a9114a03f705eb8850b93afa - languageName: node - linkType: hard - "@vitest/utils@npm:2.0.5": version: 2.0.5 resolution: "@vitest/utils@npm:2.0.5" @@ -29504,21 +29441,6 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:2.0.4": - version: 2.0.4 - resolution: "vite-node@npm:2.0.4" - dependencies: - cac: "npm:^6.7.14" - debug: "npm:^4.3.5" - pathe: "npm:^1.1.2" - tinyrainbow: "npm:^1.2.0" - vite: "npm:^5.0.0" - bin: - vite-node: vite-node.mjs - checksum: 10c0/2689b05b391b59cf3d15e1e80884e9b054f2ca90b2150cc7a08b0f234e79e6750a28cc8d107a57f005185e759c3bc020030f687065317fc37fe169ce17f4cdb7 - languageName: node - linkType: hard - "vite-node@npm:2.0.5": version: 2.0.5 resolution: "vite-node@npm:2.0.5" @@ -29599,55 +29521,6 @@ __metadata: languageName: node linkType: hard -"vitest@npm:2.0.4": - version: 2.0.4 - resolution: "vitest@npm:2.0.4" - dependencies: - "@ampproject/remapping": "npm:^2.3.0" - "@vitest/expect": "npm:2.0.4" - "@vitest/pretty-format": "npm:^2.0.4" - "@vitest/runner": "npm:2.0.4" - "@vitest/snapshot": "npm:2.0.4" - "@vitest/spy": "npm:2.0.4" - "@vitest/utils": "npm:2.0.4" - chai: "npm:^5.1.1" - debug: "npm:^4.3.5" - execa: "npm:^8.0.1" - magic-string: "npm:^0.30.10" - pathe: "npm:^1.1.2" - std-env: "npm:^3.7.0" - tinybench: "npm:^2.8.0" - tinypool: "npm:^1.0.0" - tinyrainbow: "npm:^1.2.0" - vite: "npm:^5.0.0" - vite-node: "npm:2.0.4" - why-is-node-running: "npm:^2.3.0" - peerDependencies: - "@edge-runtime/vm": "*" - "@types/node": ^18.0.0 || >=20.0.0 - "@vitest/browser": 2.0.4 - "@vitest/ui": 2.0.4 - happy-dom: "*" - jsdom: "*" - peerDependenciesMeta: - "@edge-runtime/vm": - optional: true - "@types/node": - optional: true - "@vitest/browser": - optional: true - "@vitest/ui": - optional: true - happy-dom: - optional: true - jsdom: - optional: true - bin: - vitest: vitest.mjs - checksum: 10c0/139200d0bda3270fd00641e4bd5524f78a2b1fe9a3d4a0d5ba2b6ed08bbcf6f1e711cc4bfd8b0d823628a2fcab00f822bb210bd5bf3c6a9260fd6115ea085a3d - languageName: node - linkType: hard - "vitest@npm:2.0.5": version: 2.0.5 resolution: "vitest@npm:2.0.5" From 054ebbb6a577825a846582e4d105b642021892ac Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 12 Aug 2024 16:14:15 -0700 Subject: [PATCH 192/258] Updates log messages to be consistent --- packages/jobs/src/core/Executor.ts | 8 +++++--- packages/jobs/src/core/Scheduler.ts | 4 +--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/jobs/src/core/Executor.ts b/packages/jobs/src/core/Executor.ts index eb7c2fa2f5e8..f616213debad 100644 --- a/packages/jobs/src/core/Executor.ts +++ b/packages/jobs/src/core/Executor.ts @@ -61,7 +61,7 @@ export class Executor { } async perform() { - this.logger.info(`Started job ${this.jobId}`) + this.logger.info(`[RedwoodJob] Started job ${this.jobId}`) try { const job = await loadJob({ name: this.job.name, path: this.job.path }) @@ -73,7 +73,9 @@ export class Executor { }) } catch (error: any) { // TODO(jgmw): Handle the error 'any' better - this.logger.error(`Error in job ${this.jobId}: ${error.message}`) + this.logger.error( + `[RedwoodJob] Error in job ${this.jobId}: ${error.message}`, + ) this.logger.error(error.stack) await this.adapter.error({ @@ -84,7 +86,7 @@ export class Executor { if (this.job.attempts >= this.maxAttempts) { this.logger.warn( this.job, - `Failed job ${this.jobId}: reached max attempts (${this.maxAttempts})`, + `[RedwoodJob] Failed job ${this.jobId}: reached max attempts (${this.maxAttempts})`, ) await this.adapter.failure({ job: this.job, diff --git a/packages/jobs/src/core/Scheduler.ts b/packages/jobs/src/core/Scheduler.ts index 6d0897d25a3e..26a80a7221ed 100644 --- a/packages/jobs/src/core/Scheduler.ts +++ b/packages/jobs/src/core/Scheduler.ts @@ -79,11 +79,9 @@ export class Scheduler { }) { const payload = this.buildPayload(job, jobArgs, jobOptions) - // TODO(jgmw): Ask Rob about this [RedwoodJob] prefix, consistent usage in worker, executor, etc? this.logger.info( payload, - // TODO(jgmw): Ask Rob what this prints out? - `[RedwoodJob] Scheduling ${this.constructor.name}`, + `[RedwoodJob] Scheduling ${job.name}`, ) try { From d7bc65a7bdad2650a055f90eabd05084643236d9 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 12 Aug 2024 16:14:22 -0700 Subject: [PATCH 193/258] Scheduler tests --- .../jobs/src/core/__tests__/Scheduler.old.ts | 278 ------------------ .../jobs/src/core/__tests__/Scheduler.test.js | 181 +++++++++++- 2 files changed, 180 insertions(+), 279 deletions(-) delete mode 100644 packages/jobs/src/core/__tests__/Scheduler.old.ts diff --git a/packages/jobs/src/core/__tests__/Scheduler.old.ts b/packages/jobs/src/core/__tests__/Scheduler.old.ts deleted file mode 100644 index 0c3772df3e2b..000000000000 --- a/packages/jobs/src/core/__tests__/Scheduler.old.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { beforeEach, describe, expect, vi, it } from 'vitest' - -import type CliHelpers from '@redwoodjs/cli-helpers' - -import { - DEFAULT_LOGGER, - DEFAULT_PRIORITY, - DEFAULT_QUEUE, - DEFAULT_WAIT, - DEFAULT_WAIT_UNTIL, -} from '../../consts.ts' -import { AdapterNotConfiguredError, SchedulingError } from '../../errors.ts' -import { Scheduler } from '../Scheduler.js' - -import { mockAdapter, mockLogger } from './mocks.ts' - -const FAKE_NOW = new Date('2024-01-01') -vi.useFakeTimers().setSystemTime(FAKE_NOW) - -describe('constructor', () => { - it('throws an error if adapter is not configured', () => { - expect(() => { - new Scheduler() - }).toThrow(AdapterNotConfiguredError) - }) - - it('creates this.config', () => { - const scheduler = new Scheduler({ config: { adapter: mockAdapter } }) - - expect(scheduler.config).toEqual({ adapter: mockAdapter }) - }) - - it('creates this.job', () => { - const job = () => {} - const scheduler = new Scheduler({ config: { adapter: mockAdapter }, job }) - - expect(scheduler.job).toEqual(job) - }) - - it('creates this.jobArgs', () => { - const jobArgs = ['foo', 123] - const scheduler = new Scheduler({ - config: { adapter: mockAdapter }, - jobArgs, - }) - - expect(scheduler.jobArgs).toEqual(jobArgs) - }) - - it('creates this.jobOptions', () => { - const jobOptions = { wait: 300 } - const scheduler = new Scheduler({ - config: { adapter: mockAdapter }, - jobOptions, - }) - - expect(scheduler.jobOptions).toEqual(jobOptions) - }) - - it('creates this.adapter', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter }, - }) - - expect(scheduler.adapter).toEqual(mockAdapter) - }) - - it('creates this.logger', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter, logger: mockLogger }, - }) - - expect(scheduler.logger).toEqual(mockLogger) - }) - - it('sets a default logger if none configured', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter }, - }) - - expect(scheduler.logger).toEqual(DEFAULT_LOGGER) - }) -}) - -describe('get queue()', () => { - it('returns jobOptions.queue', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter }, - jobOptions: { queue: 'foo' }, - }) - - expect(scheduler.queue).toEqual('foo') - }) - - it('falls back to config.queue', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter, queue: 'bar' }, - }) - - expect(scheduler.queue).toEqual('bar') - }) - - it('falls back to default queue', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter }, - }) - - expect(scheduler.queue).toEqual(DEFAULT_QUEUE) - }) -}) - -describe('get priority()', () => { - it('returns jobOptions.priority', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter }, - jobOptions: { priority: 1 }, - }) - - expect(scheduler.priority).toEqual(1) - }) - - it('falls back to config.priority', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter, priority: 2 }, - }) - - expect(scheduler.priority).toEqual(2) - }) - - it('falls back to default priority', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter }, - }) - - expect(scheduler.priority).toEqual(DEFAULT_PRIORITY) - }) -}) - -describe('get wait()', () => { - it('returns jobOptions.wait', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter }, - jobOptions: { wait: 300 }, - }) - - expect(scheduler.wait).toEqual(300) - }) - - it('falls back to config.wait', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter, wait: 200 }, - }) - - expect(scheduler.wait).toEqual(200) - }) - - it('falls back to default wait', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter }, - }) - - expect(scheduler.wait).toEqual(DEFAULT_WAIT) - }) -}) - -describe('get waitUntil()', () => { - it('returns jobOptions.waitUntil', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter }, - jobOptions: { waitUntil: new Date() }, - }) - - expect(scheduler.waitUntil).toEqual(expect.any(Date)) - }) - - it('falls back to config.waitUntil', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter, waitUntil: new Date() }, - }) - - expect(scheduler.waitUntil).toEqual(expect.any(Date)) - }) - - it('falls back to default waitUntil', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter }, - }) - - expect(scheduler.waitUntil).toEqual(DEFAULT_WAIT_UNTIL) - }) -}) - -describe('get runAt()', () => { - it('returns wait seconds in the future if set', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter }, - jobOptions: { wait: 300 }, - }) - - expect(scheduler.runAt).toEqual(new Date(FAKE_NOW.getTime() + 300 * 1000)) - }) - - it('returns waitUntil date if set', () => { - const waitUntil = new Date(2025, 0, 1, 12, 0, 0) - const scheduler = new Scheduler({ - config: { adapter: mockAdapter }, - jobOptions: { waitUntil }, - }) - - expect(scheduler.runAt).toEqual(waitUntil) - }) - - it('returns current date if no wait or waitUntil', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter }, - }) - - expect(scheduler.runAt).toEqual(FAKE_NOW) - }) -}) - -describe('payload()', () => { - it('returns an object with job, args, runAt, queue, and priority', () => { - const scheduler = new Scheduler({ - config: { adapter: mockAdapter }, - job: function CustomJob() {}, - jobArgs: ['foo', 123], - jobOptions: { queue: 'custom', priority: 15 }, - }) - - expect(scheduler.payload()).toEqual({ - job: 'CustomJob', - args: ['foo', 123], - runAt: FAKE_NOW, - queue: 'custom', - priority: 15, - }) - }) -}) - -describe('schedule()', () => { - beforeEach(() => { - vi.resetAllMocks() - }) - - const scheduler = new Scheduler({ - config: { adapter: mockAdapter, logger: mockLogger }, - job: function CustomJob() {}, - jobArgs: ['foo', 123], - jobOptions: { queue: 'custom', priority: 15 }, - }) - - it('calls adapter.schedule with payload', () => { - scheduler.schedule() - - expect(mockAdapter.schedule).toHaveBeenCalledWith({ - job: 'CustomJob', - args: ['foo', 123], - runAt: FAKE_NOW, - queue: 'custom', - priority: 15, - }) - }) - - it('returns true', () => { - expect(scheduler.schedule()).toEqual(true) - }) - - it('catches and rethrows any errors when scheduling', () => { - mockAdapter.schedule.mockImplementation(() => { - throw new Error('Failed to schedule') - }) - - expect(() => { - scheduler.schedule() - }).toThrow(SchedulingError) - }) -}) diff --git a/packages/jobs/src/core/__tests__/Scheduler.test.js b/packages/jobs/src/core/__tests__/Scheduler.test.js index b46bd9d48ac3..d2de3a65ef4f 100644 --- a/packages/jobs/src/core/__tests__/Scheduler.test.js +++ b/packages/jobs/src/core/__tests__/Scheduler.test.js @@ -1,9 +1,12 @@ -import { describe, expect, vi, it, beforeEach } from 'vitest' +import { describe, expect, vi, it, afterEach, beforeEach } from 'vitest' import { Scheduler } from '../Scheduler' +import * as errors from '../../errors' import { mockAdapter, mockLogger } from './mocks' +vi.useFakeTimers() + describe('constructor', () => { it('saves adapter', () => { const scheduler = new Scheduler({ @@ -23,3 +26,179 @@ describe('constructor', () => { expect(scheduler.logger).toEqual(mockLogger) }) }) + +describe('computeRunAt()', () => { + it('returns a Date `wait` seconds in the future if `wait` set', () => { + const scheduler = new Scheduler({ + adapter: mockAdapter, + logger: mockLogger, + }) + const wait = 10 + + expect(scheduler.computeRunAt({ wait })).toEqual( + new Date(Date.now() + wait * 1000), + ) + }) + + it('returns the `waitUntil` Date, if set', () => { + const scheduler = new Scheduler({ + adapter: mockAdapter, + logger: mockLogger, + }) + const waitUntil = new Date(2030, 0, 1, 12, 34, 56) + + expect(scheduler.computeRunAt({ waitUntil })).toEqual(waitUntil) + }) + + it('falls back to now', () => { + const scheduler = new Scheduler({ + adapter: mockAdapter, + logger: mockLogger, + }) + + expect(scheduler.computeRunAt({ wait: 0 })).toEqual(new Date()) + expect(scheduler.computeRunAt({ waitUntil: null })).toEqual(new Date()) + }) +}) + +describe('buildPayload()', () => { + it('returns a payload object', () => { + const scheduler = new Scheduler({ + adapter: mockAdapter, + logger: mockLogger, + }) + const job = { + name: 'JobName', + path: 'JobPath/JobPath', + queue: 'default', + priority: 25, + } + const args = [{ foo: 'bar' }] + const options = { priority: 25 } + const payload = scheduler.buildPayload(job, args, options) + + expect(payload.name).toEqual(job.name) + expect(payload.path).toEqual(job.path) + expect(payload.args).toEqual(args) + expect(payload.runAt).toEqual(new Date()) + expect(payload.queue).toEqual(job.queue) + expect(payload.priority).toEqual(job.priority) + }) + + it('falls back to a default priority', () => { + const scheduler = new Scheduler({ + adapter: mockAdapter, + logger: mockLogger, + }) + const job = { + name: 'JobName', + path: 'JobPath/JobPath', + queue: 'default', + priority: 25, + } + const payload = scheduler.buildPayload(job) + + expect(payload.priority).toEqual(job.priority) + }) + + it('takes into account a `wait` time', () => { + const scheduler = new Scheduler({ + adapter: mockAdapter, + logger: mockLogger, + }) + const job = { + name: 'JobName', + path: 'JobPath/JobPath', + queue: 'default', + priority: 25, + } + const options = { wait: 10 } + const payload = scheduler.buildPayload(job, [], options) + + expect(payload.runAt).toEqual(new Date(Date.now() + options.wait * 1000)) + }) + + it('takes into account a `waitUntil` date', () => { + const scheduler = new Scheduler({ + adapter: mockAdapter, + logger: mockLogger, + }) + const job = { + name: 'JobName', + path: 'JobPath/JobPath', + queue: 'default', + priority: 25, + } + const options = { waitUntil: new Date(2030, 0, 1, 12, 34, 56) } + const payload = scheduler.buildPayload(job, [], options) + + expect(payload.runAt).toEqual(options.waitUntil) + }) + + it('throws an error if no queue set', async () => { + const scheduler = new Scheduler({ + adapter: mockAdapter, + logger: mockLogger, + }) + const job = { + name: 'JobName', + path: 'JobPath/JobPath', + priority: 25, + } + + expect(() => scheduler.buildPayload(job)).toThrow( + errors.QueueNotDefinedError, + ) + }) +}) + +describe('schedule()', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('calls the schedule() method on the adapter', async () => { + const scheduler = new Scheduler({ + adapter: mockAdapter, + logger: mockLogger, + }) + const job = { + name: 'JobName', + path: 'JobPath/JobPath', + queue: 'default', + } + const args = [{ foo: 'bar' }] + const options = {} + + await scheduler.schedule({ job, jobArgs: args, jobOptions: options }) + + expect(mockAdapter.schedule).toHaveBeenCalledWith( + expect.objectContaining({ + name: job.name, + args: args, + }), + ) + }) + + it('re-throws any error that occurs during scheduling', async () => { + mockAdapter.schedule.mockImplementationOnce(() => { + throw new Error('Could not schedule') + }) + + const scheduler = new Scheduler({ + adapter: mockAdapter, + logger: mockLogger, + }) + const job = { + name: 'JobName', + path: 'JobPath/JobPath', + queue: 'default', + } + const args = [{ foo: 'bar' }] + const options = {} + + await expect( + scheduler.schedule({ job, jobArgs: args, jobOptions: options }), + ).rejects.toThrow(errors.SchedulingError) + }) +}) From 03145210e7cec2d5c4a185dce5b9ad7b22965978 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 12 Aug 2024 16:14:51 -0700 Subject: [PATCH 194/258] Update signature for computeRunAt to be an object --- packages/jobs/src/core/Scheduler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/jobs/src/core/Scheduler.ts b/packages/jobs/src/core/Scheduler.ts index 26a80a7221ed..1f681b01a9cb 100644 --- a/packages/jobs/src/core/Scheduler.ts +++ b/packages/jobs/src/core/Scheduler.ts @@ -34,7 +34,7 @@ export class Scheduler { } } - computeRunAt(wait: number, waitUntil: Date | null) { + computeRunAt({ wait, waitUntil }: { wait: number, waitUntil: Date | null }) { if (wait && wait > 0) { return new Date(Date.now() + wait * 1000) } else if (waitUntil) { @@ -62,7 +62,7 @@ export class Scheduler { name: job.name, path: job.path, args: args ?? [], - runAt: this.computeRunAt(wait, waitUntil), + runAt: this.computeRunAt({ wait, waitUntil }), queue: queue, priority: priority, } From 2bd6cadb41cf42c1333104053634051728649d11 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 12 Aug 2024 16:15:02 -0700 Subject: [PATCH 195/258] Await response to scheduling before returning `true` --- packages/jobs/src/core/Scheduler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jobs/src/core/Scheduler.ts b/packages/jobs/src/core/Scheduler.ts index 1f681b01a9cb..6890ad0a741c 100644 --- a/packages/jobs/src/core/Scheduler.ts +++ b/packages/jobs/src/core/Scheduler.ts @@ -85,7 +85,7 @@ export class Scheduler { ) try { - this.adapter.schedule(payload) + await this.adapter.schedule(payload) return true } catch (e) { throw new SchedulingError( From b25a278e9ab939470f456a3c325fab7cd1b11474 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 13:18:40 -0700 Subject: [PATCH 196/258] Fix DEFAULT_LOGGER usage and [RedwoodJob] prefix --- packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts | 5 ++--- packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts | 2 +- packages/jobs/src/bins/rw-jobs.ts | 1 + packages/jobs/src/core/Executor.ts | 5 ++--- packages/jobs/src/core/Scheduler.ts | 1 - packages/jobs/src/core/__tests__/Executor.test.js | 5 ++--- packages/jobs/src/core/__tests__/Worker.test.js | 5 ++--- 7 files changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts index b1023b7c466d..6ad5aa6af66a 100644 --- a/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts @@ -1,5 +1,4 @@ -import console from 'node:console' - +import { DEFAULT_LOGGER } from '../../consts' import type { BaseJob, BasicLogger, PossibleBaseJob } from '../../types' // Arguments sent to an adapter to schedule a job @@ -54,7 +53,7 @@ export abstract class BaseAdapter< constructor(options: TOptions) { this.options = options - this.logger = options?.logger ?? console + this.logger = options?.logger ?? DEFAULT_LOGGER } // It's up to the subclass to decide what to return for these functions. diff --git a/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts index f44f1a4c6fc2..7abffd19115b 100644 --- a/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts @@ -222,7 +222,7 @@ export class PrismaAdapter extends BaseAdapter { } override error({ job, error }: ErrorOptions) { - this.logger.debug(`Job ${job.id} failure`) + this.logger.debug(`[RedwoodJob] Job ${job.id} failure`) const data: FailureData = { lockedAt: null, diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index 3f21aa3dea85..c5050afae1c5 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -2,6 +2,7 @@ // Coordinates the worker processes: running attached in [work] mode or // detaching in [start] mode. +import console from 'node:console' import type { ChildProcess } from 'node:child_process' import { fork, exec } from 'node:child_process' diff --git a/packages/jobs/src/core/Executor.ts b/packages/jobs/src/core/Executor.ts index f616213debad..d1bd4326457a 100644 --- a/packages/jobs/src/core/Executor.ts +++ b/packages/jobs/src/core/Executor.ts @@ -1,12 +1,11 @@ // Used by the job runner to execute a job and track success or failure -import console from 'node:console' - import type { BaseAdapter } from '../adapters/BaseAdapter/BaseAdapter' import { DEFAULT_MAX_ATTEMPTS, DEFAULT_DELETE_FAILED_JOBS, DEFAULT_DELETE_SUCCESSFUL_JOBS, + DEFAULT_LOGGER, } from '../consts' import { AdapterRequiredError, JobRequiredError } from '../errors' import { loadJob } from '../loaders' @@ -22,7 +21,7 @@ interface Options { } export const DEFAULTS = { - logger: console, + logger: DEFAULT_LOGGER, maxAttempts: DEFAULT_MAX_ATTEMPTS, deleteFailedJobs: DEFAULT_DELETE_FAILED_JOBS, deleteSuccessfulJobs: DEFAULT_DELETE_SUCCESSFUL_JOBS, diff --git a/packages/jobs/src/core/Scheduler.ts b/packages/jobs/src/core/Scheduler.ts index 6890ad0a741c..3efa8fa267ef 100644 --- a/packages/jobs/src/core/Scheduler.ts +++ b/packages/jobs/src/core/Scheduler.ts @@ -25,7 +25,6 @@ export class Scheduler { logger: NonNullable['logger']> constructor({ adapter, logger }: SchedulerConfig) { - // TODO(jgmw): Confirm everywhere else uses this DEFAULT_LOGGER this.logger = logger ?? DEFAULT_LOGGER this.adapter = adapter diff --git a/packages/jobs/src/core/__tests__/Executor.test.js b/packages/jobs/src/core/__tests__/Executor.test.js index 1e9947695118..f34405735ccd 100644 --- a/packages/jobs/src/core/__tests__/Executor.test.js +++ b/packages/jobs/src/core/__tests__/Executor.test.js @@ -1,11 +1,10 @@ -import console from 'node:console' - import { beforeEach, describe, expect, vi, it } from 'vitest' import * as errors from '../../errors' import { Executor } from '../Executor' import { mockLogger } from './mocks' +import { DEFAULT_LOGGER } from '../../consts' const mocks = vi.hoisted(() => { return { @@ -52,7 +51,7 @@ describe('constructor', () => { const options = { adapter: 'adapter', job: 'job' } const exector = new Executor(options) - expect(exector.logger).toEqual(console) + expect(exector.logger).toEqual(DEFAULT_LOGGER) }) it('throws AdapterRequiredError if adapter is not provided', () => { diff --git a/packages/jobs/src/core/__tests__/Worker.test.js b/packages/jobs/src/core/__tests__/Worker.test.js index eae0ccbb1c98..856403ce4e29 100644 --- a/packages/jobs/src/core/__tests__/Worker.test.js +++ b/packages/jobs/src/core/__tests__/Worker.test.js @@ -1,5 +1,3 @@ -import console from 'node:console' - import { beforeEach, describe, expect, vi, it } from 'vitest' import * as errors from '../../errors' @@ -7,6 +5,7 @@ import { Executor } from '../Executor' import { Worker } from '../Worker' import { mockLogger } from './mocks' +import { DEFAULT_LOGGER } from '../../consts' // don't execute any code inside Executor, just spy on whether functions are // called @@ -42,7 +41,7 @@ describe('constructor', () => { const options = { adapter: 'adapter', queues: ['*'] } const worker = new Worker(options) - expect(worker.logger).toEqual(console) + expect(worker.logger).toEqual(DEFAULT_LOGGER) }) it('extracts processName from options to variable', () => { From 88db895640d749695636431d73994846176e2fdf Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 13:18:51 -0700 Subject: [PATCH 197/258] Add adapter exports --- packages/jobs/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/jobs/src/index.ts b/packages/jobs/src/index.ts index e4afd126753b..c73494657a47 100644 --- a/packages/jobs/src/index.ts +++ b/packages/jobs/src/index.ts @@ -3,3 +3,6 @@ export * from './errors' export { JobManager } from './core/JobManager' export { Executor } from './core/Executor' export { Worker } from './core/Worker' + +export { BaseAdapter } from './adapters/BaseAdapter/BaseAdapter' +export { PrismaAdapter } from './adapters/PrismaAdapter/PrismaAdapter' From d2f59ee3de259799ee1e3b2d5bc361a4b30c0423 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 13:21:08 -0700 Subject: [PATCH 198/258] Fix/remove TODOs --- packages/cli/src/commands/generate/job/job.js | 2 -- .../adapters/PrismaAdapter/PrismaAdapter.ts | 32 ++++++++----------- packages/jobs/src/core/Executor.ts | 1 - .../jobs/src/core/__tests__/Executor.test.js | 2 -- 4 files changed, 14 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/commands/generate/job/job.js b/packages/cli/src/commands/generate/job/job.js index 44c411173e0f..fd1c30952a48 100644 --- a/packages/cli/src/commands/generate/job/job.js +++ b/packages/cli/src/commands/generate/job/job.js @@ -156,8 +156,6 @@ export const handler = async ({ name, force, ...rest }) => { let queueName = 'default' - // TODO(jgmw): It would be better if imported the src version so we could "hit" - // on the queue name more often // Attempt to read the first queue in the users job config file try { const jobsManagerFile = getPaths().api.distJobsConfig diff --git a/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts index 7abffd19115b..e3826e53a3ce 100644 --- a/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts @@ -196,20 +196,16 @@ export class PrismaAdapter extends BaseAdapter { return undefined } - // TODO(jgmw): This comment doesn't seem to link with the implementation below - // TODO(jgmw): I think maybe these all need to be async and awaited? - // Prisma queries are lazily evaluated and only sent to the db when they are - // awaited, so do the await here to ensure they actually run. Otherwise the - // user must always await `performLater()` or the job won't actually be - // scheduled. - override success({ job, deleteJob }: SuccessOptions) { - this.logger.debug(`Job ${job.id} success`) + // awaited, so do the await here to ensure they actually run (if the user + // doesn't await the Promise then the queries will never be executed!) + override async success({ job, deleteJob }: SuccessOptions) { + this.logger.debug(`[RedwoodJob] Job ${job.id} success`) if (deleteJob) { - this.accessor.delete({ where: { id: job.id } }) + await this.accessor.delete({ where: { id: job.id } }) } else { - this.accessor.update({ + await this.accessor.update({ where: { id: job.id }, data: { lockedAt: null, @@ -221,7 +217,7 @@ export class PrismaAdapter extends BaseAdapter { } } - override error({ job, error }: ErrorOptions) { + override async error({ job, error }: ErrorOptions) { this.logger.debug(`[RedwoodJob] Job ${job.id} failure`) const data: FailureData = { @@ -235,18 +231,18 @@ export class PrismaAdapter extends BaseAdapter { new Date().getTime() + this.backoffMilliseconds(job.attempts), ) - this.accessor.update({ + await this.accessor.update({ where: { id: job.id }, data, }) } // Job has had too many attempts, it has now permanently failed. - override failure({ job, deleteJob }: FailureOptions) { + override async failure({ job, deleteJob }: FailureOptions) { if (deleteJob) { - this.accessor.delete({ where: { id: job.id } }) + await this.accessor.delete({ where: { id: job.id } }) } else { - this.accessor.update({ + await this.accessor.update({ where: { id: job.id }, data: { failedAt: new Date() }, }) @@ -262,7 +258,7 @@ export class PrismaAdapter extends BaseAdapter { queue, priority, }: SchedulePayload) { - this.accessor.create({ + await this.accessor.create({ data: { handler: JSON.stringify({ name, path, args }), runAt, @@ -272,8 +268,8 @@ export class PrismaAdapter extends BaseAdapter { }) } - override clear() { - this.accessor.deleteMany() + override async clear() { + await this.accessor.deleteMany() } backoffMilliseconds(attempts: number) { diff --git a/packages/jobs/src/core/Executor.ts b/packages/jobs/src/core/Executor.ts index d1bd4326457a..aea63f1faa59 100644 --- a/packages/jobs/src/core/Executor.ts +++ b/packages/jobs/src/core/Executor.ts @@ -71,7 +71,6 @@ export class Executor { deleteJob: DEFAULT_DELETE_SUCCESSFUL_JOBS, }) } catch (error: any) { - // TODO(jgmw): Handle the error 'any' better this.logger.error( `[RedwoodJob] Error in job ${this.jobId}: ${error.message}`, ) diff --git a/packages/jobs/src/core/__tests__/Executor.test.js b/packages/jobs/src/core/__tests__/Executor.test.js index f34405735ccd..bb157e350089 100644 --- a/packages/jobs/src/core/__tests__/Executor.test.js +++ b/packages/jobs/src/core/__tests__/Executor.test.js @@ -72,8 +72,6 @@ describe('perform', () => { vi.resetAllMocks() }) - // TODO(jgmw): I'm not sure I understand the comment below. - // TODO once these dynamic imports are converted into loadJob in shared, just mock out the result of loadJob it('invokes the `perform` method on the job class', async () => { const mockAdapter = { success: vi.fn() } const options = { From f39ae362d6bebe318984c07329c4c76a5e9cb58c Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 13:21:20 -0700 Subject: [PATCH 199/258] Add @prisma/client as dev dependency --- packages/jobs/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/jobs/package.json b/packages/jobs/package.json index 7d627375b906..ca4fc64e0160 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -26,6 +26,7 @@ "test:watch": "vitest" }, "devDependencies": { + "@prisma/client": "5.18.0", "@redwoodjs/project-config": "workspace:*", "tsx": "4.16.2", "typescript": "5.5.4", From 594ab3eebd2edbe49a2edebb313ecf877c2a659f Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 13:21:31 -0700 Subject: [PATCH 200/258] Remove unused babel mock --- packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js b/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js index 99b21659caf8..6262e4764d87 100644 --- a/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js +++ b/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js @@ -2,9 +2,6 @@ import { describe, expect, vi, it } from 'vitest' // import * as worker from '../worker' -// so that registerApiSideBabelHook() doesn't freak out about redwood.toml -vi.mock('@redwoodjs/babel-config') - describe('worker', () => { it('placeholder', () => { expect(true).toBeTruthy() From 3b61b8e8fda19420600308bf1602c7c490c838fd Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 13:22:42 -0700 Subject: [PATCH 201/258] Move worker instantiation to JobManager --- packages/jobs/src/bins/rw-jobs-worker.ts | 41 ++++-------------------- packages/jobs/src/core/JobManager.ts | 30 +++++++++++++++-- 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index 3e71f1b7034d..3408e8814bc0 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -10,16 +10,11 @@ import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' import { - DEFAULT_DELETE_FAILED_JOBS, - DEFAULT_MAX_ATTEMPTS, - DEFAULT_MAX_RUNTIME, - DEFAULT_SLEEP_DELAY, - DEFAULT_WORK_QUEUE, DEFAULT_LOGGER, PROCESS_TITLE_PREFIX, } from '../consts' import { Worker } from '../core/Worker' -import { AdapterNotFoundError, WorkerConfigIndexNotFoundError } from '../errors' +import { WorkerConfigIndexNotFoundError } from '../errors' import { loadJobsManager } from '../loaders' import type { BasicLogger } from '../types' @@ -93,52 +88,28 @@ const setupSignals = ({ const main = async () => { const { index, id, clear, workoff } = await parseArgs(process.argv) - let jobsConfig + let manager try { - jobsConfig = await loadJobsManager() + manager = await loadJobsManager() } catch (e) { console.error(e) process.exit(1) } - const workerConfig = jobsConfig.workers[index] - - // Exit if the indexed worker options doesn't exist + const workerConfig = manager.workers[index] if (!workerConfig) { throw new WorkerConfigIndexNotFoundError(index) } - const adapter = jobsConfig.adapters[workerConfig.adapter] - - // Exit if the named adapter isn't exported - if (!adapter) { - throw new AdapterNotFoundError(workerConfig.adapter) - } - - // Use worker logger, or jobs worker, or fallback to console - const logger = workerConfig.logger ?? jobsConfig.logger ?? DEFAULT_LOGGER - + const logger = workerConfig.logger ?? manager.logger ?? DEFAULT_LOGGER logger.info( `[${process.title}] Starting work at ${new Date().toISOString()}...`, ) setProcessTitle({ id, queue: workerConfig.queue }) - const worker = new Worker({ - adapter, - logger, - maxAttempts: workerConfig.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, - maxRuntime: workerConfig.maxRuntime ?? DEFAULT_MAX_RUNTIME, - sleepDelay: workerConfig.sleepDelay ?? DEFAULT_SLEEP_DELAY, - deleteFailedJobs: - workerConfig.deleteFailedJobs ?? DEFAULT_DELETE_FAILED_JOBS, - processName: process.title, - queues: [workerConfig.queue ?? DEFAULT_WORK_QUEUE].flat(), - workoff, - clear, - }) - + const worker = manager.createWorker({ index, clear, workoff }) worker.run().then(() => { logger.info(`[${process.title}] Worker finished, shutting down.`) process.exit(0) diff --git a/packages/jobs/src/core/JobManager.ts b/packages/jobs/src/core/JobManager.ts index f8aade02f1be..6305cffbeb68 100644 --- a/packages/jobs/src/core/JobManager.ts +++ b/packages/jobs/src/core/JobManager.ts @@ -1,3 +1,4 @@ +import { AdapterNotFoundError } from '../errors' import type { Adapters, BasicLogger, @@ -8,8 +9,16 @@ import type { ScheduleJobOptions, WorkerConfig, } from '../types' +import type { WorkerOptions } from './Worker' import { Scheduler } from './Scheduler' +import { Worker } from './Worker' + +export interface CreateWorkerArgs { + index: number + workoff: WorkerOptions['workoff'] + clear: WorkerOptions['clear'] +} export class JobManager< TAdapters extends Adapters, @@ -52,7 +61,24 @@ export class JobManager< return jobDefinition as Job } - createWorker() { - // coming soon + createWorker({ index, workoff, clear }: CreateWorkerArgs) { + const config = this.workers[index] + const adapter = this.adapters[config.adapter] + if (!adapter) { + throw new AdapterNotFoundError(config.adapter.toString()) + } + + return new Worker({ + adapter: this.adapters[config.adapter], + logger: config.logger || this.logger, + maxAttempts: config.maxAttempts, + maxRuntime: config.maxRuntime, + sleepDelay: config.sleepDelay, + deleteFailedJobs: config.deleteFailedJobs, + processName: process.title, + queues: [config.queue].flat(), + workoff, + clear, + }) } } From 484da828d4ae401b9609dc149113b3a99b1c1ddf Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 13:22:59 -0700 Subject: [PATCH 202/258] Update command line flag descriptions --- packages/jobs/src/bins/rw-jobs-worker.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index 3408e8814bc0..3ed8747a8219 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -26,23 +26,23 @@ const parseArgs = (argv: string[]) => { .option('index', { type: 'number', description: - 'The index of the `workers` property from the exported `jobs` config to use to configure this worker', + 'The index of the `workers` array from the exported `jobs` config to use to configure this worker', default: 0, }) .option('id', { type: 'number', - description: 'The worker count id to identify this worker', + description: 'The worker count id to identify this worker. ie: if you had `count: 2` in your worker config, you would have two workers with ids 0 and 1', default: 0, }) .option('workoff', { type: 'boolean', default: false, - description: 'Work off all jobs in the queue and exit', + description: 'Work off all jobs in the queue(s) and exit', }) .option('clear', { type: 'boolean', default: false, - description: 'Remove all jobs in the queue and exit', + description: 'Remove all jobs in all queues and exit', }) .help().argv } From 83ca4e6d42fc98782fd5996c2b0b87c6a64f6cd7 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 13:23:14 -0700 Subject: [PATCH 203/258] =?UTF-8?q?Don=E2=80=99t=20execute=20script=20if?= =?UTF-8?q?=20imported=20in=20test=20env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/jobs/src/bins/rw-jobs-worker.ts | 5 ++++- packages/jobs/src/bins/rw-jobs.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index 3ed8747a8219..c61a0e853b18 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -118,4 +118,7 @@ const main = async () => { setupSignals({ worker, logger }) } -main() +// Don't actaully run the worker if we're in a test environment +if (process.env.NODE_ENV !== 'test') { + main() +} diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index c5050afae1c5..d9e45a643d91 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -304,4 +304,7 @@ const main = async () => { } } -main() +// Don't actaully run the worker if we're in a test environment +if (process.env.NODE_ENV !== 'test') { + main() +} From 3c7432e6bd3b78cea9c408052db4228b0607e72e Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 13:23:23 -0700 Subject: [PATCH 204/258] Reorg imports --- packages/jobs/src/bins/rw-jobs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index d9e45a643d91..be83a5f46092 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -3,11 +3,11 @@ // Coordinates the worker processes: running attached in [work] mode or // detaching in [start] mode. import console from 'node:console' +import process from 'node:process' import type { ChildProcess } from 'node:child_process' import { fork, exec } from 'node:child_process' import path from 'node:path' -import process from 'node:process' import { setTimeout } from 'node:timers' import { hideBin } from 'yargs/helpers' From 9749bee05fdf89f653e961c3f7b70830352074c3 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 13:23:43 -0700 Subject: [PATCH 205/258] Fix types in Worker --- packages/jobs/src/core/Worker.ts | 19 +++---------------- packages/jobs/src/types.ts | 2 +- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/packages/jobs/src/core/Worker.ts b/packages/jobs/src/core/Worker.ts index e0a27a2223f5..c27f7da3651a 100644 --- a/packages/jobs/src/core/Worker.ts +++ b/packages/jobs/src/core/Worker.ts @@ -16,7 +16,7 @@ import type { BasicLogger } from '../types' import { Executor } from './Executor' -interface WorkerOptions { +export interface WorkerOptions { // required adapter: BaseAdapter processName: string @@ -35,21 +35,9 @@ interface WorkerOptions { forever?: boolean } -interface DefaultOptions { - logger: WorkerOptions['logger'] - clear: WorkerOptions['clear'] - maxAttempts: WorkerOptions['maxAttempts'] - maxRuntime: WorkerOptions['maxRuntime'] - deleteSuccessfulJobs: WorkerOptions['deleteSuccessfulJobs'] - deleteFailedJobs: WorkerOptions['deleteFailedJobs'] - sleepDelay: WorkerOptions['sleepDelay'] - workoff: WorkerOptions['workoff'] - forever: WorkerOptions['forever'] -} - type CompleteOptions = Required -const DEFAULT_OPTIONS: DefaultOptions = { +const DEFAULT_OPTIONS = { logger: DEFAULT_LOGGER, clear: false, maxAttempts: DEFAULT_MAX_ATTEMPTS, @@ -78,8 +66,7 @@ export class Worker { lastCheckTime: Date constructor(options: WorkerOptions) { - // TODO(jgmw) - this.options = { ...DEFAULT_OPTIONS, ...options } as CompleteOptions + this.options = { ...DEFAULT_OPTIONS, ...options } if (!options?.adapter) { throw new AdapterRequiredError() diff --git a/packages/jobs/src/types.ts b/packages/jobs/src/types.ts index 126b3a5cc771..8c3781905bcb 100644 --- a/packages/jobs/src/types.ts +++ b/packages/jobs/src/types.ts @@ -24,7 +24,7 @@ export type PossibleBaseJob = BaseJob | undefined export type Adapters = Record -export interface WorkerConfig { +export interface WorkerConfig { /** * The name of the adapter to use for this worker. This must be one of the keys * in the `adapters` object when you created the `JobManager`. From 7dc7e60094290ed205c0de41e6682163ce7ca175 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 14:15:34 -0700 Subject: [PATCH 206/258] Use completeJobPath to test if file exists --- packages/jobs/src/loaders.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/jobs/src/loaders.ts b/packages/jobs/src/loaders.ts index 574e4873b5d1..e7968d3e07ac 100644 --- a/packages/jobs/src/loaders.ts +++ b/packages/jobs/src/loaders.ts @@ -41,13 +41,14 @@ export const loadJob = async ({ }: JobComputedProperties): Promise> => { // Confirm the specific job file exists const completeJobPath = path.join(getPaths().api.distJobs, jobPath) + '.js' - const importPath = makeFilePath(completeJobPath) - if (!fs.existsSync(importPath)) { + + if (!fs.existsSync(completeJobPath)) { throw new JobNotFoundError(jobName) } - // Import the job + const importPath = makeFilePath(completeJobPath) const jobModule = await import(importPath) + if (!jobModule[jobName]) { throw new JobNotFoundError(jobName) } From b2501863663a6b6f2523fed994e002c3be9fdb30 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 14:16:05 -0700 Subject: [PATCH 207/258] Update templates --- .../cli/src/commands/generate/job/templates/job.ts.template | 2 +- .../cli/src/commands/generate/job/templates/test.ts.template | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/generate/job/templates/job.ts.template b/packages/cli/src/commands/generate/job/templates/job.ts.template index d967aa4f1116..8c049fb3fdab 100644 --- a/packages/cli/src/commands/generate/job/templates/job.ts.template +++ b/packages/cli/src/commands/generate/job/templates/job.ts.template @@ -3,6 +3,6 @@ import { jobs } from 'src/lib/jobs' export const ${name}Job = jobs.createJob({ queue: '${queueName}', perform: async () => { - // job implementation here + jobs.logger.info('${name}Job is performing...') } }) diff --git a/packages/cli/src/commands/generate/job/templates/test.ts.template b/packages/cli/src/commands/generate/job/templates/test.ts.template index 554fd30f38e9..9ff7a20db4df 100644 --- a/packages/cli/src/commands/generate/job/templates/test.ts.template +++ b/packages/cli/src/commands/generate/job/templates/test.ts.template @@ -1,6 +1,6 @@ import { ${name}Job } from './${name}Job' -describe('${name}', () => { +describe('${name}Job', () => { it('should not throw any errors', async () => { await expect(${name}Job.perform()).resolves.not.toThrow() }) From 688b0a5814623528806ffc80379964f2acceaa2e Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 14:16:26 -0700 Subject: [PATCH 208/258] loadEnvFiles in jobs worker, switch to warn() level for startup logs --- packages/jobs/src/bins/rw-jobs-worker.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index c61a0e853b18..ba8a94700156 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -9,6 +9,10 @@ import process from 'node:process' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' +// @ts-expect-error - doesn't understand dual CJS/ESM export +import * as cliHelperLoadEnv from '@redwoodjs/cli-helpers/loadEnvFiles' +const { loadEnvFiles } = cliHelperLoadEnv + import { DEFAULT_LOGGER, PROCESS_TITLE_PREFIX, @@ -18,6 +22,8 @@ import { WorkerConfigIndexNotFoundError } from '../errors' import { loadJobsManager } from '../loaders' import type { BasicLogger } from '../types' +loadEnvFiles() + const parseArgs = (argv: string[]) => { return yargs(hideBin(argv)) .usage( @@ -78,7 +84,7 @@ const setupSignals = ({ // instead in which case we exit immediately no matter what state the worker is // in process.on('SIGTERM', () => { - logger.info( + logger.warn( `[${process.title}] SIGTERM received at ${new Date().toISOString()}, exiting now!`, ) process.exit(0) @@ -103,7 +109,7 @@ const main = async () => { } const logger = workerConfig.logger ?? manager.logger ?? DEFAULT_LOGGER - logger.info( + logger.warn( `[${process.title}] Starting work at ${new Date().toISOString()}...`, ) From 0b395860fb17eea33cb7181aff8bb263772654a8 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 14:16:34 -0700 Subject: [PATCH 209/258] Fix script name --- packages/jobs/src/bins/rw-jobs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index be83a5f46092..9301c5fd3d0c 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -165,7 +165,7 @@ const stopWorkers = async ({ const clearQueue = ({ logger }: { logger: BasicLogger }) => { logger.warn(`Starting worker to clear job queue...`) - fork(path.join(__dirname, 'worker.js'), ['--clear']) + fork(path.join(__dirname, 'rw-jobs-worker.js'), ['--clear']) } const signalSetup = ({ From 5b27f313159104a55c5f30d4d07da310960e870e Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 15:23:40 -0700 Subject: [PATCH 210/258] Adds changeset --- .changesets/10906.md | 135 ------------------------------------------- .changesets/11238.md | 14 +++++ 2 files changed, 14 insertions(+), 135 deletions(-) delete mode 100644 .changesets/10906.md create mode 100644 .changesets/11238.md diff --git a/.changesets/10906.md b/.changesets/10906.md deleted file mode 100644 index a84098508181..000000000000 --- a/.changesets/10906.md +++ /dev/null @@ -1,135 +0,0 @@ -- Adds background job scheduling and execution (#10906) by @cannikin - -This new package provides scheduling and processing of background jobs. We want everything needed to run a modern web application to be included in Redwood itself—you shouldn't need any third party integrations if you don't want. Background jobs have been sorely missed, but the time has come! (If you do want to use a third party service we have had an [integration with Inngest](https://community.redwoodjs.com/t/ship-background-jobs-crons-webhooks-and-reliable-workflows-in-record-time-with-inngest-and-redwoodjs/4866) since May of 2023!) - -## What's Included - -- A base `RedwoodJob` class from which your own custom jobs will extend. You only need to fill out the details of a single `perform()` action, accepting whatever arguments you want, and the underlying RedwoodJob code will take care of scheduling, delaying, running, and, in the case your job fails, recording the error and rescheduling in the future for a retry. -- Backend adapters for storing your jobs. Today we're shipping with a `PrismaAdapter` but we also provide a `BaseAdapter` from which you can extend and build your own. -- A persistent process to watch for new jobs and execute them. It can be run in dev mode, which stays attached to your console so you can monitor and execute jobs in development, or in daemon mode which detaches from the console and runs in the background forever (you'll use this mode in production). - -Decoupling the jobs from their backends means you can swap out backends as your app grows, or even use different backends for different jobs! - -The actual Worker and Executor classes that know how to find a job and work on it are self-contained, so you can write your own runner if you want. - -## Features - -- Named queues: you can schedule jobs in separate named queues and have a different number of workers monitoring each one—makes it much easier to scale your background processing -- Priority: give your jobs a priority from 1 (highest) to 100 (lowest). Workers will sort available jobs by priority, working the most important ones first. -- Configurable delay: run your job as soon as possible (default), wait a number of seconds before running, or run at a specific time in the future -- Run inline: instead of scheduling to run in the background, run immediately -- Auto-retries with backoff: if your job fails it will back off at the rate of `attempts ** 4` for a default of 24 tries, the time between the last two attempts is a little over three days. The number of max retries is configurable per job. -- Integrates with Redwood's [logger](https://docs.redwoodjs.com/docs/logger): use your existing one in `api/src/lib/logger` or create a new one just for job logging - -## How it Works - -Using the `PrismaAdapter` means your jobs are stored in your database. The `yarn rw setup jobs` script will add a `BackgroundJob` model in your `schema.prisma` file. Any job that is invoked with `.performLater()` will add a row to this table: - -``` -WelcomeEmailJob.performLater({ user.email }) -``` - -If using the `PrismaAdapter`, any arguments you want to give to your job must be serializable as JSON since the values will be stored in the database as text. - -The persistent job workers (started in dev with `yarn rw jobs work` or detached to run in the background with `yarn rw jobs start`) will periodically check the database for any jobs that are qualified to run: not already locked by another worker and with a `runAt` time before or equal to right now. They'll lock the record, instantiate your job class and call `perform()` on it, passing in the arguments you gave when scheduling it. - -- If the job succeeds it is removed from the database -- If the job fails the error is recorded, the job is rescheduled to try again, and the lock is removed - -Repeat until the queue is empty! - -## Usage - -### Setup - -To simplify the setup, run the included setup script: - -``` -yarn rw setup jobs -``` - -This creates `api/src/lib/jobs` with the basic config included to get up and running, as well as the model added to your `schema.prisma` file. - -You can generate a job with the shell ready to go: - -``` -yarn rw g job WelcomeEmail -``` - -This creates a file at `api/src/jobs/WelcomeEmailJob.js` along with the shell of your job. All you need to is fill out the `perform()` function: - -```javascript -// api/src/jobs/WelcomeEmailJob.js - -export class WelcomeEmailJob extends RedwoodJob { - perform(email) { - // send email... - } -} -``` - -### Scheduling - -A typical place you'd use this job would be in a service. In this case, let's add it to the `users` service after creating a user: - -```javascript -// api/src/services/users/users.js - -export const createUser = async ({ input }) { - const user = await db.user.create({ data: input }) - await WelcomeEmailJob.performLater(user.email) - return user -}) -``` - -With the above syntax your job will run as soon as possible, in the queue named "default" and with a priority of 50. You can also delay your job for, say, 5 minutes: - -```javascript -OnboardingJob.set({ wait: 300 }).performLater(user.email) -``` - -Or run it at a specific time in the future: - -```javascript -MilleniumReminderJob.set({ waitUntil: new Date(2999, 11, 31, 12, 0, 0) }).performLater(user.email) -``` - -There are lots of ways to customize the scheduling and worker processes. Check out the docs for the full list! - -### Execution - -To run your jobs, start up the runner: - -```bash -yarn rw jobs work -``` - -This process will stay attached the console and continually look for new jobs and execute them as they are found. To work on whatever outstanding jobs there are and then exit, use the `workoff` mode instead. - -To run the worker(s) in the background, use the `start` mode: - -```bash -yarn rw jobs start -``` - -To stop them: - -```bash -yarn rw jobs stop -``` - -You can start more than one worker by passing the `-n` flag: - -```bash -yarn rw jobs start -n 4 -``` - -If you want to specify that some workers only work on certain named queues: - -```bash -yarn rw jobs start -n default:2,email:1 -``` - -Make sure you pass the same flags to the `stop` process as the `start` so it knows which ones to stop. You can `restart` your workers as well. - -In production you'll want to hook the workers up to a process monitor as, just like with any other process, they could die unexpectedly. More on this in the docs. diff --git a/.changesets/11238.md b/.changesets/11238.md new file mode 100644 index 000000000000..5b7c38d0af40 --- /dev/null +++ b/.changesets/11238.md @@ -0,0 +1,14 @@ +- Adds background job scheduling and execution (#10906) by @cannikin + +This new package provides scheduling and processing of background jobs. We want everything needed to run a modern web application to be included in Redwood itself—you shouldn't need any third party integrations if you don't want. + +Background jobs have been sorely missed, but the time has come! (If you do want to use a third party service we have had an [integration with Inngest](https://community.redwoodjs.com/t/ship-background-jobs-crons-webhooks-and-reliable-workflows-in-record-time-with-inngest-and-redwoodjs/4866) since May of 2023!) + +## Features + +- Named queues: you can schedule jobs in separate named queues and have a different number of workers monitoring each one—makes it much easier to scale your background processing +- Priority: give your jobs a priority from 1 (highest) to 100 (lowest). Workers will sort available jobs by priority, working the most important ones first. +- Configurable delay: run your job as soon as possible (default), wait a number of seconds before running, or run at a specific time in the future +- Auto-retries with backoff: if your job fails it will back off at the rate of attempts ** 4 for a default of 24 tries, the time between the last two attempts is a little over three days. +- Run inline: instead of scheduling to run in the background, run immediately +- Integrates with Redwood's [logger](https://docs.redwoodjs.com/docs/logger): use your existing one in api/src/lib/logger or create a new one just for job logging From 3ececde0c9d2aad411ed7c8fbbfcb2fe70d4d0d3 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 15:23:48 -0700 Subject: [PATCH 211/258] Removes comment --- packages/jobs/src/bins/rw-jobs.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index 9301c5fd3d0c..8be5f561417c 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -113,7 +113,6 @@ const startWorkers = ({ } // fork the worker process - // TODO squiggles under __dirname, but import.meta.dirname blows up when running the process const worker = fork(path.join(__dirname, 'rw-jobs-worker.js'), workerArgs, { detached: detach, stdio: detach ? 'ignore' : 'inherit', From 9ca39cd184edd197dec28a1aa048bb943bd2e735 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 15:29:52 -0700 Subject: [PATCH 212/258] Adds `id` as a required PossibleJob attribute --- packages/jobs/src/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/jobs/src/types.ts b/packages/jobs/src/types.ts index 8c3781905bcb..f97f64ed5153 100644 --- a/packages/jobs/src/types.ts +++ b/packages/jobs/src/types.ts @@ -15,6 +15,7 @@ export interface BasicLogger { // This is the minimum interface that a "job" must conform to in order to be // scheduled and executed by Redwood's job engine. export interface BaseJob { + id: string | number name: string path: string args: unknown[] From d803d7a76b816ed0535cfd2600a5985c1665222a Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 15:31:09 -0700 Subject: [PATCH 213/258] Use job.id and unique job identifier string in log messages --- packages/jobs/src/core/Executor.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/jobs/src/core/Executor.ts b/packages/jobs/src/core/Executor.ts index aea63f1faa59..aed833258a49 100644 --- a/packages/jobs/src/core/Executor.ts +++ b/packages/jobs/src/core/Executor.ts @@ -55,12 +55,12 @@ export class Executor { this.deleteSuccessfulJobs = this.options.deleteSuccessfulJobs } - get jobId() { - return `${this.job.path}:${this.job.name}` + get jobIdentifier() { + return `${this.job.id} (${this.job.path}:${this.job.name})` } async perform() { - this.logger.info(`[RedwoodJob] Started job ${this.jobId}`) + this.logger.info(`[RedwoodJob] Started job ${this.jobIdentifier}`) try { const job = await loadJob({ name: this.job.name, path: this.job.path }) @@ -72,7 +72,7 @@ export class Executor { }) } catch (error: any) { this.logger.error( - `[RedwoodJob] Error in job ${this.jobId}: ${error.message}`, + `[RedwoodJob] Error in job ${this.jobIdentifier}: ${error.message}`, ) this.logger.error(error.stack) @@ -84,7 +84,7 @@ export class Executor { if (this.job.attempts >= this.maxAttempts) { this.logger.warn( this.job, - `[RedwoodJob] Failed job ${this.jobId}: reached max attempts (${this.maxAttempts})`, + `[RedwoodJob] Failed job ${this.jobIdentifier}: reached max attempts (${this.maxAttempts})`, ) await this.adapter.failure({ job: this.job, From c86d7dcbea6bcc8617ef8740ff1751880dde64ee Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 13 Aug 2024 15:46:58 -0700 Subject: [PATCH 214/258] Remove NODE_ENV insert in jobs setup --- .../src/commands/setup/jobs/jobsHandler.js | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/packages/cli/src/commands/setup/jobs/jobsHandler.js b/packages/cli/src/commands/setup/jobs/jobsHandler.js index 6e5254141642..930d9a36ff77 100644 --- a/packages/cli/src/commands/setup/jobs/jobsHandler.js +++ b/packages/cli/src/commands/setup/jobs/jobsHandler.js @@ -104,28 +104,6 @@ const tasks = async ({ force }) => { }) }, }, - { - title: 'Adding NODE_ENV var...', - task: () => { - let envFile = fs - .readFileSync( - path.resolve(getPaths().base, '.env.defaults'), - 'utf-8', - ) - .toString() - - envFile = envFile + '\nNODE_ENV=development\n' - - writeFile(path.resolve(getPaths().base, '.env.defaults'), envFile, { - overwriteExisting: true, - }) - }, - skip: () => { - if (process.env.NODE_ENV) { - return 'NODE_ENV already set, skipping' - } - }, - }, addApiPackages([jobsPackage]), { title: 'One more thing...', From 0c00238572fa4bab83bf36b9c75667fb1334e505 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Tue, 13 Aug 2024 23:56:39 +0100 Subject: [PATCH 215/258] update deps --- packages/jobs/package.json | 2 +- yarn.lock | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/jobs/package.json b/packages/jobs/package.json index ca4fc64e0160..9d8dcb2a349c 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -28,7 +28,7 @@ "devDependencies": { "@prisma/client": "5.18.0", "@redwoodjs/project-config": "workspace:*", - "tsx": "4.16.2", + "tsx": "4.17.0", "typescript": "5.5.4", "vitest": "2.0.5" } diff --git a/yarn.lock b/yarn.lock index b70aa636d62d..f89d5d3b1635 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8252,8 +8252,9 @@ __metadata: version: 0.0.0-use.local resolution: "@redwoodjs/jobs@workspace:packages/jobs" dependencies: + "@prisma/client": "npm:5.18.0" "@redwoodjs/project-config": "workspace:*" - tsx: "npm:4.16.2" + tsx: "npm:4.17.0" typescript: "npm:5.5.4" vitest: "npm:2.0.5" bin: From 406033ea8c1db4229968f07c2499416b0e55f97d Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Mon, 19 Aug 2024 17:09:51 +0100 Subject: [PATCH 216/258] update paths test snapshot --- .../src/__tests__/paths.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/project-config/src/__tests__/paths.test.ts b/packages/project-config/src/__tests__/paths.test.ts index 6f85bf54a312..7446c131d90d 100644 --- a/packages/project-config/src/__tests__/paths.test.ts +++ b/packages/project-config/src/__tests__/paths.test.ts @@ -94,6 +94,11 @@ describe('paths', () => { types: path.join(FIXTURE_BASEDIR, 'api', 'types'), models: path.join(FIXTURE_BASEDIR, 'api', 'src', 'models'), mail: path.join(FIXTURE_BASEDIR, 'api', 'src', 'mail'), + jobs: path.join(FIXTURE_BASEDIR, 'api', 'src', 'jobs'), + jobsConfig: null, + distJobs: path.join(FIXTURE_BASEDIR, 'api', 'dist', 'jobs'), + distJobsConfig: null, + logger: path.join(FIXTURE_BASEDIR, 'api', 'src', 'lib', 'logger.ts'), }, web: { routes: path.join(FIXTURE_BASEDIR, 'web', 'src', 'Routes.tsx'), @@ -363,6 +368,11 @@ describe('paths', () => { types: path.join(FIXTURE_BASEDIR, 'api', 'types'), models: path.join(FIXTURE_BASEDIR, 'api', 'src', 'models'), mail: path.join(FIXTURE_BASEDIR, 'api', 'src', 'mail'), + jobs: path.join(FIXTURE_BASEDIR, 'api', 'src', 'jobs'), + jobsConfig: null, + distJobs: path.join(FIXTURE_BASEDIR, 'api', 'dist', 'jobs'), + distJobsConfig: null, + logger: null, }, web: { routes: path.join(FIXTURE_BASEDIR, 'web', 'src', 'Routes.js'), @@ -678,6 +688,11 @@ describe('paths', () => { types: path.join(FIXTURE_BASEDIR, 'api', 'types'), models: path.join(FIXTURE_BASEDIR, 'api', 'src', 'models'), mail: path.join(FIXTURE_BASEDIR, 'api', 'src', 'mail'), + jobs: path.join(FIXTURE_BASEDIR, 'api', 'src', 'jobs'), + jobsConfig: null, + distJobs: path.join(FIXTURE_BASEDIR, 'api', 'dist', 'jobs'), + distJobsConfig: null, + logger: null, }, web: { routes: path.join(FIXTURE_BASEDIR, 'web', 'src', 'Routes.js'), @@ -952,6 +967,11 @@ describe('paths', () => { types: path.join(FIXTURE_BASEDIR, 'api', 'types'), models: path.join(FIXTURE_BASEDIR, 'api', 'src', 'models'), mail: path.join(FIXTURE_BASEDIR, 'api', 'src', 'mail'), + jobs: path.join(FIXTURE_BASEDIR, 'api', 'src', 'jobs'), + jobsConfig: null, + distJobs: path.join(FIXTURE_BASEDIR, 'api', 'dist', 'jobs'), + distJobsConfig: null, + logger: path.join(FIXTURE_BASEDIR, 'api', 'src', 'lib', 'logger.ts'), }, web: { routes: path.join(FIXTURE_BASEDIR, 'web', 'src', 'Routes.tsx'), From f5be93e6eea0f665734bdd9971591aa037f3ac4d Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Mon, 19 Aug 2024 17:11:16 +0100 Subject: [PATCH 217/258] remove unneeded includes --- packages/jobs/vitest.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/jobs/vitest.config.ts b/packages/jobs/vitest.config.ts index 78a196e006c1..139a0882cc51 100644 --- a/packages/jobs/vitest.config.ts +++ b/packages/jobs/vitest.config.ts @@ -3,7 +3,6 @@ import { defineConfig, configDefaults } from 'vitest/config' export default defineConfig({ test: { testTimeout: 15_000, - include: ['**/__tests__/**/*.test.[jt]s'], exclude: [...configDefaults.exclude, '**/fixtures', '**/dist'], }, }) From 8c67e4da58531992e759f498bc8b8c2608787524 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Mon, 19 Aug 2024 17:12:16 +0100 Subject: [PATCH 218/258] update tsconfig --- packages/jobs/tsconfig.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/jobs/tsconfig.json b/packages/jobs/tsconfig.json index ababfe904bfe..62e8ee9de229 100644 --- a/packages/jobs/tsconfig.json +++ b/packages/jobs/tsconfig.json @@ -3,13 +3,14 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "moduleResolution": "NodeNext", - "module": "NodeNext" + "moduleResolution": "Node16", + "module": "Node16", + "tsBuildInfoFile": "./tsconfig.tsbuildinfo" }, "include": ["src/**/*"], "references": [ { "path": "../babel-config" }, { "path": "../cli-helpers" }, - { "path": "../project-config" }, + { "path": "../project-config" } ] } From bcf658fbe6a90b1d239ef3739a3bb69c728422dc Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Mon, 19 Aug 2024 17:17:12 +0100 Subject: [PATCH 219/258] switch comments to jsdoc --- packages/jobs/src/errors.ts | 76 ++++++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/packages/jobs/src/errors.ts b/packages/jobs/src/errors.ts index 3a86959f0d4e..1914aa3461f9 100644 --- a/packages/jobs/src/errors.ts +++ b/packages/jobs/src/errors.ts @@ -1,6 +1,8 @@ const JOBS_CONFIG_FILENAME = 'jobs.ts/js' -// Parent class for any RedwoodJob-related error +/** + * Parent class for any RedwoodJob-related error + */ export class RedwoodJobError extends Error { constructor(message: string) { super(message) @@ -8,50 +10,64 @@ export class RedwoodJobError extends Error { } } -// Thrown when trying to configure a scheduler without an adapter +/** + * Thrown when trying to configure a scheduler without an adapter + */ export class AdapterNotConfiguredError extends RedwoodJobError { constructor() { super('No adapter configured for the job scheduler') } } -// Thrown when the Worker or Executor is instantiated without an adapter +/** + * Thrown when the Worker or Executor is instantiated without an adapter + */ export class AdapterRequiredError extends RedwoodJobError { constructor() { super('`adapter` is required to perform a job') } } -// Thrown when the Worker is instantiated without an array of queues +/** + * Thrown when the Worker is instantiated without an array of queues + */ export class QueuesRequiredError extends RedwoodJobError { constructor() { super('`queues` is required to find a job to run') } } -// Thrown when the Executor is instantiated without a job +/** + * Thrown when the Executor is instantiated without a job + */ export class JobRequiredError extends RedwoodJobError { constructor() { super('`job` is required to perform a job') } } -// Thrown when a job with the given handler is not found in the filesystem +/** + * Thrown when a job with the given handler is not found in the filesystem + */ export class JobNotFoundError extends RedwoodJobError { constructor(name: string) { super(`Job \`${name}\` not found in the filesystem`) } } -// Thrown when a job file exists, but the export does not match the filename +/** + * Thrown when a job file exists, but the export does not match the filename + */ export class JobExportNotFoundError extends RedwoodJobError { constructor(name: string) { super(`Job file \`${name}\` does not export a class with the same name`) } } -// Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js and -// the file does not exist +/** + * Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js|ts and + * the file does not exist + */ export class JobsLibNotFoundError extends RedwoodJobError { constructor() { super( @@ -60,7 +76,9 @@ export class JobsLibNotFoundError extends RedwoodJobError { } } -// Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js +/** + * Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js|ts + */ export class AdapterNotFoundError extends RedwoodJobError { constructor(name: string) { super( @@ -69,7 +87,9 @@ export class AdapterNotFoundError extends RedwoodJobError { } } -// Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js +/** + * Thrown when the runner tries to import `logger` from api/src/lib/jobs.js|ts + */ export class LoggerNotFoundError extends RedwoodJobError { constructor(name: string) { super( @@ -78,22 +98,28 @@ export class LoggerNotFoundError extends RedwoodJobError { } } -// Thrown when the runner tries to import `workerConfig` from api/src/lib/jobs.js +/** + * Thrown when the runner tries to import `workerConfig` from api/src/lib/jobs.js|ts + */ export class WorkerConfigNotFoundError extends RedwoodJobError { constructor(name: string) { super(`api/src/lib/#{JOBS_CONFIG_FILENAME} does not export \`${name}\``) } } -// Parent class for any job where we want to wrap the underlying error in our -// own. Use by extending this class and passing the original error to the -// constructor: -// -// try { -// throw new Error('Generic error') -// } catch (e) { -// throw new RethrowJobError('Custom Error Message', e) -// } +/** + * Parent class for any job error where we want to wrap the underlying error + * in our own. Use by extending this class and passing the original error to + * the constructor: + * + * ```typescript + * try { + * throw new Error('Generic error') + * } catch (e) { + * throw new RethrowJobError('Custom Error Message', e) + * } + * ``` + */ export class RethrownJobError extends RedwoodJobError { originalError: Error stackBeforeRethrow: string | undefined @@ -121,14 +147,18 @@ export class RethrownJobError extends RedwoodJobError { } } -// Thrown when there is an error scheduling a job, wraps the underlying error +/** + * Thrown when there is an error scheduling a job, wraps the underlying error + */ export class SchedulingError extends RethrownJobError { constructor(message: string, error: Error) { super(message, error) } } -// Thrown when there is an error performing a job, wraps the underlying error +/** + * Thrown when there is an error performing a job, wraps the underlying error + */ export class PerformError extends RethrownJobError { constructor(message: string, error: Error) { super(message, error) From 08596c3232d414c44bb832cbd095ab5ed5b4e95a Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Mon, 19 Aug 2024 17:22:22 +0100 Subject: [PATCH 220/258] initial lint --- .../jobs/src/bins/__tests__/rw-jobs-worker.test.js | 2 +- packages/jobs/src/bins/rw-jobs-worker.ts | 10 ++++------ packages/jobs/src/bins/rw-jobs.ts | 14 +++++++++----- packages/jobs/src/core/JobManager.ts | 2 +- packages/jobs/src/core/__tests__/Executor.test.js | 2 +- packages/jobs/src/core/__tests__/Scheduler.test.js | 4 ++-- packages/jobs/src/core/__tests__/Worker.test.js | 2 +- 7 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js b/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js index 6262e4764d87..607f02c63085 100644 --- a/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js +++ b/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js @@ -1,4 +1,4 @@ -import { describe, expect, vi, it } from 'vitest' +import { describe, expect, it } from 'vitest' // import * as worker from '../worker' diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index ba8a94700156..9482522cef98 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -13,11 +13,8 @@ import yargs from 'yargs/yargs' import * as cliHelperLoadEnv from '@redwoodjs/cli-helpers/loadEnvFiles' const { loadEnvFiles } = cliHelperLoadEnv -import { - DEFAULT_LOGGER, - PROCESS_TITLE_PREFIX, -} from '../consts' -import { Worker } from '../core/Worker' +import { DEFAULT_LOGGER, PROCESS_TITLE_PREFIX } from '../consts' +import type { Worker } from '../core/Worker' import { WorkerConfigIndexNotFoundError } from '../errors' import { loadJobsManager } from '../loaders' import type { BasicLogger } from '../types' @@ -37,7 +34,8 @@ const parseArgs = (argv: string[]) => { }) .option('id', { type: 'number', - description: 'The worker count id to identify this worker. ie: if you had `count: 2` in your worker config, you would have two workers with ids 0 and 1', + description: + 'The worker count id to identify this worker. ie: if you had `count: 2` in your worker config, you would have two workers with ids 0 and 1', default: 0, }) .option('workoff', { diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index 8be5f561417c..a67b15eb04d0 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -2,12 +2,12 @@ // Coordinates the worker processes: running attached in [work] mode or // detaching in [start] mode. -import console from 'node:console' -import process from 'node:process' import type { ChildProcess } from 'node:child_process' import { fork, exec } from 'node:child_process' +import console from 'node:console' import path from 'node:path' +import process from 'node:process' import { setTimeout } from 'node:timers' import { hideBin } from 'yargs/helpers' @@ -21,7 +21,7 @@ import { DEFAULT_LOGGER, PROCESS_TITLE_PREFIX } from '../consts' import { loadJobsManager } from '../loaders' import type { Adapters, BasicLogger, WorkerConfig } from '../types' -export type NumWorkersConfig = Array<[number, number]> +export type NumWorkersConfig = [number, number][] loadEnvFiles() @@ -171,7 +171,7 @@ const signalSetup = ({ workers, logger, }: { - workers: Array + workers: ChildProcess[] logger: BasicLogger }) => { // Keep track of how many times the user has pressed ctrl-c @@ -191,7 +191,11 @@ const signalSetup = ({ logger.info(message) workers.forEach((worker) => { - sigtermCount > 1 ? worker.kill('SIGTERM') : worker.kill('SIGINT') + if (sigtermCount > 1) { + worker.kill('SIGTERM') + } else { + worker.kill('SIGINT') + } }) }) } diff --git a/packages/jobs/src/core/JobManager.ts b/packages/jobs/src/core/JobManager.ts index 6305cffbeb68..3277d90bf000 100644 --- a/packages/jobs/src/core/JobManager.ts +++ b/packages/jobs/src/core/JobManager.ts @@ -9,9 +9,9 @@ import type { ScheduleJobOptions, WorkerConfig, } from '../types' -import type { WorkerOptions } from './Worker' import { Scheduler } from './Scheduler' +import type { WorkerOptions } from './Worker' import { Worker } from './Worker' export interface CreateWorkerArgs { diff --git a/packages/jobs/src/core/__tests__/Executor.test.js b/packages/jobs/src/core/__tests__/Executor.test.js index bb157e350089..8e114e5dba31 100644 --- a/packages/jobs/src/core/__tests__/Executor.test.js +++ b/packages/jobs/src/core/__tests__/Executor.test.js @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, vi, it } from 'vitest' +import { DEFAULT_LOGGER } from '../../consts' import * as errors from '../../errors' import { Executor } from '../Executor' import { mockLogger } from './mocks' -import { DEFAULT_LOGGER } from '../../consts' const mocks = vi.hoisted(() => { return { diff --git a/packages/jobs/src/core/__tests__/Scheduler.test.js b/packages/jobs/src/core/__tests__/Scheduler.test.js index d2de3a65ef4f..6513306c70c4 100644 --- a/packages/jobs/src/core/__tests__/Scheduler.test.js +++ b/packages/jobs/src/core/__tests__/Scheduler.test.js @@ -1,7 +1,7 @@ -import { describe, expect, vi, it, afterEach, beforeEach } from 'vitest' +import { describe, expect, vi, it, beforeEach } from 'vitest' -import { Scheduler } from '../Scheduler' import * as errors from '../../errors' +import { Scheduler } from '../Scheduler' import { mockAdapter, mockLogger } from './mocks' diff --git a/packages/jobs/src/core/__tests__/Worker.test.js b/packages/jobs/src/core/__tests__/Worker.test.js index 856403ce4e29..c2ecdc6966a2 100644 --- a/packages/jobs/src/core/__tests__/Worker.test.js +++ b/packages/jobs/src/core/__tests__/Worker.test.js @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, vi, it } from 'vitest' +import { DEFAULT_LOGGER } from '../../consts' import * as errors from '../../errors' import { Executor } from '../Executor' import { Worker } from '../Worker' import { mockLogger } from './mocks' -import { DEFAULT_LOGGER } from '../../consts' // don't execute any code inside Executor, just spy on whether functions are // called From 2c6f3ed9ddcedb495c7a0a3c02af2e9fda70ed4e Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Mon, 19 Aug 2024 17:24:11 +0100 Subject: [PATCH 221/258] format --- .changesets/11238.md | 2 +- docs/docs/background-jobs.md | 120 ++++++++++++++-------------- packages/jobs/package.json | 6 +- packages/jobs/src/core/Scheduler.ts | 7 +- packages/jobs/src/types.ts | 5 +- 5 files changed, 70 insertions(+), 70 deletions(-) diff --git a/.changesets/11238.md b/.changesets/11238.md index 5b7c38d0af40..397b61166158 100644 --- a/.changesets/11238.md +++ b/.changesets/11238.md @@ -9,6 +9,6 @@ Background jobs have been sorely missed, but the time has come! (If you do want - Named queues: you can schedule jobs in separate named queues and have a different number of workers monitoring each one—makes it much easier to scale your background processing - Priority: give your jobs a priority from 1 (highest) to 100 (lowest). Workers will sort available jobs by priority, working the most important ones first. - Configurable delay: run your job as soon as possible (default), wait a number of seconds before running, or run at a specific time in the future -- Auto-retries with backoff: if your job fails it will back off at the rate of attempts ** 4 for a default of 24 tries, the time between the last two attempts is a little over three days. +- Auto-retries with backoff: if your job fails it will back off at the rate of attempts \*\* 4 for a default of 24 tries, the time between the last two attempts is a little over three days. - Run inline: instead of scheduling to run in the background, run immediately - Integrates with Redwood's [logger](https://docs.redwoodjs.com/docs/logger): use your existing one in api/src/lib/logger or create a new one just for job logging diff --git a/docs/docs/background-jobs.md b/docs/docs/background-jobs.md index 7bd138a90687..78f64414b8ef 100644 --- a/docs/docs/background-jobs.md +++ b/docs/docs/background-jobs.md @@ -24,7 +24,7 @@ In your `.env` or `.env.defaults` file, add an entry for `NODE_ENV`: NODE_ENV=development ``` -If `NODE_ENV` is *not* set then the job runner will assume you're running a production-like environment and get jobs from `api/dist/jobs`. If you try to start a job worker and see this error: +If `NODE_ENV` is _not_ set then the job runner will assume you're running a production-like environment and get jobs from `api/dist/jobs`. If you try to start a job worker and see this error: ``` JobsLibNotFoundError: api/src/lib/jobs.ts not found. Run `yarn rw setup jobs` to create this file and configure background jobs @@ -179,7 +179,7 @@ await jobs.sendWelcomeEmail.set({ wait: 300 }).performLater(user.id) :::info Job Run Time Guarantees -Job is never *guaranteed* to run at an exact time. The worker could be busy working on other jobs and can't get to yours just yet. The time you set for your job to run is the *soonest* it could possibly run. +Job is never _guaranteed_ to run at an exact time. The worker could be busy working on other jobs and can't get to yours just yet. The time you set for your job to run is the _soonest_ it could possibly run. If you absolutely, positively need your job to run right now, take a look at the `performNow()` function instead of `performLater()`. The response from the server will wait until the job is complete, but you'll know for sure that it has run. @@ -232,14 +232,14 @@ If that quick start covered your use case, great, you're done for now! Take a lo The rest of this doc describes more advanced usage, like: -* Assigning jobs to named **queues** -* Setting a **priority** so that some jobs always run before others -* Using different adapters and loggers on a per-job basis -* Starting more than one worker -* Having some workers focus on only certain queues -* Configuring individual workers to use different adapters -* Manually workers without the job runner monitoring them -* And more! +- Assigning jobs to named **queues** +- Setting a **priority** so that some jobs always run before others +- Using different adapters and loggers on a per-job basis +- Starting more than one worker +- Having some workers focus on only certain queues +- Configuring individual workers to use different adapters +- Manually workers without the job runner monitoring them +- And more! ## RedwoodJob (Global) Configuration @@ -277,8 +277,8 @@ Jobs will inherit a default queue name of `"default"` and a priority of `50`. Config can be given the following options: -* `adapter`: **[required]** The adapter to use for scheduling your job. -* `logger`: Made available as `this.logger` within your job for logging whatever you'd like during the `perform()` step. This defaults to `console`. +- `adapter`: **[required]** The adapter to use for scheduling your job. +- `logger`: Made available as `this.logger` within your job for logging whatever you'd like during the `perform()` step. This defaults to `console`. ### Exporting `jobs` @@ -308,7 +308,7 @@ export const updateProduct = async ({ id, input }) => { } ``` -It *is* possible to skip this export altogther and import and schedule individual jobs manually: +It _is_ possible to skip this export altogther and import and schedule individual jobs manually: ```js // api/src/services/products/products.js @@ -327,8 +327,8 @@ HOWEVER, this will lead to unexpected behavior if you're not aware of the follow If you don't export a `jobs` object and then `import` it when you want to schedule a job, the `Redwood.config()` line will never be executed and your jobs will not receive a default configuration! This means you'll need to either: -* Invoke `RedwoodJob.config()` somewhere before scheduling your job -* Manually set the adapter/logger/etc. in each of your jobs. +- Invoke `RedwoodJob.config()` somewhere before scheduling your job +- Manually set the adapter/logger/etc. in each of your jobs. We'll see examples of configuring the individual jobs with an adapter and logger below. @@ -338,10 +338,10 @@ We'll see examples of configuring the individual jobs with an adapter and logger All jobs have some default configuration set for you if don't do anything different: -* `queue` : jobs can be in named queues and have dedicated workers that only pull jobs from that queue. This lets you scale not only your entire job runner independently of the rest of your app, but scale the individual queues as well. By default, all jobs will go in a queue named "default" if you don't override it. -* `priority` : within a single queue you can jobs that are more or less important. The workers will pull jobs off the queue with a higher priority before working on ones with a lower priority. The default priority is `50`. A lower number is *higher* in priority than a lower number. ie. the workers will work on a job with a priority of `1` before they work on one with a priority of `100`. -* `logger` : jobs will log to the console if you don't tell them otherwise. The logger exported from `api/src/lib/logger.js` works well the job runner, so we recommend using that by setting it in `RedwoodJob.config()` or on a per-job basis. -* `adapter` : the adapter to use to store this jobs. There is no default adapter set for jobs, so you'll need to set this in `RedwoodJob.config()` or on a per-job basis. +- `queue` : jobs can be in named queues and have dedicated workers that only pull jobs from that queue. This lets you scale not only your entire job runner independently of the rest of your app, but scale the individual queues as well. By default, all jobs will go in a queue named "default" if you don't override it. +- `priority` : within a single queue you can jobs that are more or less important. The workers will pull jobs off the queue with a higher priority before working on ones with a lower priority. The default priority is `50`. A lower number is _higher_ in priority than a lower number. ie. the workers will work on a job with a priority of `1` before they work on one with a priority of `100`. +- `logger` : jobs will log to the console if you don't tell them otherwise. The logger exported from `api/src/lib/logger.js` works well the job runner, so we recommend using that by setting it in `RedwoodJob.config()` or on a per-job basis. +- `adapter` : the adapter to use to store this jobs. There is no default adapter set for jobs, so you'll need to set this in `RedwoodJob.config()` or on a per-job basis. If you don't do anything special, a job will inherit the adapter and logger you set with the call to `RedwoodJob.config()`. However, you can override these settings on a per-job basis. You don't have to set all of them, you can use them in any combination you want: @@ -380,9 +380,9 @@ const adapter = new PrismaAdapter({ }) ``` -* `db`: **[required]** an instance of `PrismaClient` that the adapter will use to store, find and update the status of jobs. In most cases this will be the `db` variable exported from `api/src/lib/db.js`. This must be set in order for the adapter to be initialized! -* `model`: the name of the model that was created to store jobs. This defaults to `BackgroundJob` -* `logger`: events that occur within the adapter will be logged using this. This defaults to `console` but the `logger` exported from `api/src/lib/logger` works great, too. +- `db`: **[required]** an instance of `PrismaClient` that the adapter will use to store, find and update the status of jobs. In most cases this will be the `db` variable exported from `api/src/lib/db.js`. This must be set in order for the adapter to be initialized! +- `model`: the name of the model that was created to store jobs. This defaults to `BackgroundJob` +- `logger`: events that occur within the adapter will be logged using this. This defaults to `console` but the `logger` exported from `api/src/lib/logger` works great, too. ## Job Scheduling @@ -399,13 +399,13 @@ const job = new SendWelcomeEmailJob() job.set({ wait: 300 }).performLater() ``` -You can also set options when you create the instance. For example, if *every* invocation of this job should wait 5 minutes, no need to `set()` that each time, just initialize it with that option: +You can also set options when you create the instance. For example, if _every_ invocation of this job should wait 5 minutes, no need to `set()` that each time, just initialize it with that option: ```js // api/src/lib/jobs.js export const jobs = { // highlight-next-line - sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }) + sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }), } // api/src/services/users/users.js @@ -423,7 +423,7 @@ export const createUser = async ({ input }) => { // api/src/lib/jobs.js export const jobs = { // highlight-next-line - sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }) // 5 minutes + sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }), // 5 minutes } // api/src/services/users/users.js @@ -449,18 +449,18 @@ Once you have your instance you can inspect the options set on it: ```js const job = new SendWelcomeEmail() // set by RedwoodJob.config or static properies -job.adapter // => PrismaAdapter instance -jog.logger // => logger instance +job.adapter // => PrismaAdapter instance +jog.logger // => logger instance // set via `set()` or provided during job instantiaion -job.queue // => 'default' -job.priority // => 50 -job.wait // => 300 +job.queue // => 'default' +job.priority // => 50 +job.wait // => 300 job.waitUntil // => null // computed internally -job.runAt // => 2025-07-27 12:35:00 UTC - // ^ the actual computed Date of now + `wait` +job.runAt // => 2025-07-27 12:35:00 UTC +// ^ the actual computed Date of now + `wait` ``` :::info @@ -478,9 +478,9 @@ import { AnnualReportGenerationJob } from 'api/src/jobs/AnnualReportGenerationJo AnnualReportGenerationJob.performLater() // or -AnnualReportGenerationJob - .set({ waitUntil: new Date(2025, 0, 1) }) - .performLater() +AnnualReportGenerationJob.set({ + waitUntil: new Date(2025, 0, 1), +}).performLater() ``` Using this syntax comes with a caveat: since no `RedwoodJob.config()` was called you would need to configure the `adapter` and `logger` directly on `AnnualReportGenerationJob` via static properties (unless you were sure that `RedwoodJob.config()` was called somewhere before this code executes). See the note at the end of the [Exporting jobs](#exporting-jobs) section explaining this limitation. @@ -489,10 +489,10 @@ Using this syntax comes with a caveat: since no `RedwoodJob.config()` was called You can pass several options in a `set()` call on your instance or class: -* `wait`: number of seconds to wait before the job will run -* `waitUntil`: a specific `Date` in the future to run at -* `queue`: the named queue to put this job in (overrides any `static queue` set on the job itself) -* `priority`: the priority to give this job (overrides any `static priority` set on the job itself) +- `wait`: number of seconds to wait before the job will run +- `waitUntil`: a specific `Date` in the future to run at +- `queue`: the named queue to put this job in (overrides any `static queue` set on the job itself) +- `priority`: the priority to give this job (overrides any `static priority` set on the job itself) ## Job Runner @@ -512,7 +512,7 @@ This process will stay attached the console and continually look for new jobs an :::caution Long running jobs -It's currently up to you to make sure your job completes before your `maxRuntime` limit is reached! NodeJS Promises are not truly cancelable: you can reject early, but any Promises that were started *inside* will continue running unless they are also early rejected, recursively forever. +It's currently up to you to make sure your job completes before your `maxRuntime` limit is reached! NodeJS Promises are not truly cancelable: you can reject early, but any Promises that were started _inside_ will continue running unless they are also early rejected, recursively forever. The only way to guarantee a job will completely stop no matter what is for your job to spawn an actual OS level process with a timeout that kills it after a certain amount of time. We may add this functionality natively to Jobs in the near future: let us know if you'd benefit from this being built in! @@ -636,16 +636,16 @@ The job runner sets the `--maxAttempts`, `--maxRuntime` and `--sleepDelay` flags ### Flags -* `--id` : a number identifier that's set as part of the process name. For example starting a worker with * `--id=0` and then inspecting your process list will show one running named `rw-job-worker.0` -* `--queue` : the named queue for the worker to focus on. Leaving this flag off defaults to watching all queues. This will also effect the process title name. Setting `--queue=email` would lead to process named * `rw-job-worker.email.0` (assuming `--id=0`) -* `--workoff` : if this flag is present then the worker will terminate itself after the queue it is watching is empty -* `--clear` : if this flag is present the worker will call the `clear()` function on the adapter (which should remove all jobs in storage) and then exit -* `--maxAttempts` : The max number of times to retry a job before considering it failed, and will not be retried. Defaults to `24`. -* `--maxRuntime` : The max amount of time, in seconds, a job should run before being eligible to be picked up by another worker instead, effectively starting over. Defaults to `14400` which is 4 hours. -* `--sleepDelay` : How long to wait, in seconds, before querying the adapter for another available job to run. Careful not to DDoS your job storage system by setting this too low! -* `--deleteFailedJobs` : If `maxAttempts` is reached, what to do with the job. Defaults to `false` meaning the job will remain in the queue, but the workers will not pick it up to work on again. Useful for debugging why a job may have failed. -* `--adapter` : The name of the variable exported from `api/src/lib/jobs.js` to use as the adapter for this worker instance. Defalts to `adapter`. The worker will error on startup if this variable is not exported by your jobs config file. -* `--logger` : The name of the variable exported from `api/src/lib/jobs.js` to us as the logger for this worker instance. Defaults to `logger` but will fall back to `console` if no `logger` is exported. +- `--id` : a number identifier that's set as part of the process name. For example starting a worker with \* `--id=0` and then inspecting your process list will show one running named `rw-job-worker.0` +- `--queue` : the named queue for the worker to focus on. Leaving this flag off defaults to watching all queues. This will also effect the process title name. Setting `--queue=email` would lead to process named \* `rw-job-worker.email.0` (assuming `--id=0`) +- `--workoff` : if this flag is present then the worker will terminate itself after the queue it is watching is empty +- `--clear` : if this flag is present the worker will call the `clear()` function on the adapter (which should remove all jobs in storage) and then exit +- `--maxAttempts` : The max number of times to retry a job before considering it failed, and will not be retried. Defaults to `24`. +- `--maxRuntime` : The max amount of time, in seconds, a job should run before being eligible to be picked up by another worker instead, effectively starting over. Defaults to `14400` which is 4 hours. +- `--sleepDelay` : How long to wait, in seconds, before querying the adapter for another available job to run. Careful not to DDoS your job storage system by setting this too low! +- `--deleteFailedJobs` : If `maxAttempts` is reached, what to do with the job. Defaults to `false` meaning the job will remain in the queue, but the workers will not pick it up to work on again. Useful for debugging why a job may have failed. +- `--adapter` : The name of the variable exported from `api/src/lib/jobs.js` to use as the adapter for this worker instance. Defalts to `adapter`. The worker will error on startup if this variable is not exported by your jobs config file. +- `--logger` : The name of the variable exported from `api/src/lib/jobs.js` to us as the logger for this worker instance. Defaults to `logger` but will fall back to `console` if no `logger` is exported. ## Creating Your Own Adapter @@ -653,18 +653,18 @@ We'd love the community to contribue adapters for Redwood Job! Take a look at th The general gist of the required functions: -* `find()` should find a job to be run, lock it and return it (minimum return of and object containing `handler` and `args` properties) -* `schedule()` accepts `handler`, `args`, `runAt`, `queue` and `priority` and should store the job -* `success()` accepts the same job object returned from `find()` and does whatever success means (delete the job from the queue, most likely) -* `failure()` accepts the same job object returned from `find()` and does whatever failure means (unlock the job and reschedule a time for it to run again in the future, or give up if `maxAttempts` is reached) -* `clear()` remove all jobs from the queue (mostly used in development) +- `find()` should find a job to be run, lock it and return it (minimum return of and object containing `handler` and `args` properties) +- `schedule()` accepts `handler`, `args`, `runAt`, `queue` and `priority` and should store the job +- `success()` accepts the same job object returned from `find()` and does whatever success means (delete the job from the queue, most likely) +- `failure()` accepts the same job object returned from `find()` and does whatever failure means (unlock the job and reschedule a time for it to run again in the future, or give up if `maxAttempts` is reached) +- `clear()` remove all jobs from the queue (mostly used in development) ## The Future There's still more to add to background jobs! Our current TODO list: -* More adapters: Redis, SQS, RabbitMQ... -* RW Studio integration: monitor the state of your outstanding jobs -* Baremetal integration: if jobs are enabled, monitor the workers with pm2 -* Recurring jobs -* Livecycle hooks: `beforePerform()`, `afterPerform()`, `afterSuccess()`, `afterFailure()` +- More adapters: Redis, SQS, RabbitMQ... +- RW Studio integration: monitor the state of your outstanding jobs +- Baremetal integration: if jobs are enabled, monitor the workers with pm2 +- Recurring jobs +- Livecycle hooks: `beforePerform()`, `afterPerform()`, `afterSuccess()`, `afterFailure()` diff --git a/packages/jobs/package.json b/packages/jobs/package.json index 9d8dcb2a349c..a9ce53717b71 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -9,13 +9,13 @@ "license": "MIT", "main": "./dist/index.js", "types": "./dist/index.d.ts", - "files": [ - "dist" - ], "bin": { "rw-jobs": "./dist/bins/rw-jobs.js", "rw-jobs-worker": "./dist/bins/rw-jobs-worker.js" }, + "files": [ + "dist" + ], "scripts": { "build": "tsx ./build.mts && yarn build:types", "build:pack": "yarn pack -o redwoodjs-jobs.tgz", diff --git a/packages/jobs/src/core/Scheduler.ts b/packages/jobs/src/core/Scheduler.ts index 3efa8fa267ef..a4d80f92b93a 100644 --- a/packages/jobs/src/core/Scheduler.ts +++ b/packages/jobs/src/core/Scheduler.ts @@ -33,7 +33,7 @@ export class Scheduler { } } - computeRunAt({ wait, waitUntil }: { wait: number, waitUntil: Date | null }) { + computeRunAt({ wait, waitUntil }: { wait: number; waitUntil: Date | null }) { if (wait && wait > 0) { return new Date(Date.now() + wait * 1000) } else if (waitUntil) { @@ -78,10 +78,7 @@ export class Scheduler { }) { const payload = this.buildPayload(job, jobArgs, jobOptions) - this.logger.info( - payload, - `[RedwoodJob] Scheduling ${job.name}`, - ) + this.logger.info(payload, `[RedwoodJob] Scheduling ${job.name}`) try { await this.adapter.schedule(payload) diff --git a/packages/jobs/src/types.ts b/packages/jobs/src/types.ts index f97f64ed5153..e07d57278a41 100644 --- a/packages/jobs/src/types.ts +++ b/packages/jobs/src/types.ts @@ -25,7 +25,10 @@ export type PossibleBaseJob = BaseJob | undefined export type Adapters = Record -export interface WorkerConfig { +export interface WorkerConfig< + TAdapters extends Adapters, + TQueues extends string[], +> { /** * The name of the adapter to use for this worker. This must be one of the keys * in the `adapters` object when you created the `JobManager`. From 47a1b880e1d3cdc530b831cc84bc8c9e9bab9812 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Mon, 19 Aug 2024 17:26:58 +0100 Subject: [PATCH 222/258] remove cjs/esm hack --- packages/jobs/src/bins/rw-jobs-worker.ts | 4 +--- packages/jobs/src/bins/rw-jobs.ts | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index 9482522cef98..f5cde1ede9f6 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -9,9 +9,7 @@ import process from 'node:process' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' -// @ts-expect-error - doesn't understand dual CJS/ESM export -import * as cliHelperLoadEnv from '@redwoodjs/cli-helpers/loadEnvFiles' -const { loadEnvFiles } = cliHelperLoadEnv +import { loadEnvFiles } from '@redwoodjs/cli-helpers/loadEnvFiles' import { DEFAULT_LOGGER, PROCESS_TITLE_PREFIX } from '../consts' import type { Worker } from '../core/Worker' diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index a67b15eb04d0..0fc97ea04aa6 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -13,9 +13,7 @@ import { setTimeout } from 'node:timers' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' -// @ts-expect-error - doesn't understand dual CJS/ESM export -import * as cliHelperLoadEnv from '@redwoodjs/cli-helpers/loadEnvFiles' -const { loadEnvFiles } = cliHelperLoadEnv +import { loadEnvFiles } from '@redwoodjs/cli-helpers/loadEnvFiles' import { DEFAULT_LOGGER, PROCESS_TITLE_PREFIX } from '../consts' import { loadJobsManager } from '../loaders' From e672f557bef4003f8421f7ef766dffac171995d7 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Mon, 19 Aug 2024 17:27:06 +0100 Subject: [PATCH 223/258] don't skip placeholder test --- packages/jobs/src/bins/__tests__/rw-jobs.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jobs/src/bins/__tests__/rw-jobs.test.js b/packages/jobs/src/bins/__tests__/rw-jobs.test.js index 7d9d2b9a746c..32fe44e693ba 100644 --- a/packages/jobs/src/bins/__tests__/rw-jobs.test.js +++ b/packages/jobs/src/bins/__tests__/rw-jobs.test.js @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' // import * as runner from '../runner' describe('runner', () => { - it.skip('placeholder', () => { + it('placeholder', () => { expect(true).toBeTruthy() }) }) From 1e8aa72301ab559bbd4e539b28bcc8b9af03aaff Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Mon, 19 Aug 2024 18:28:44 +0100 Subject: [PATCH 224/258] add missing word --- docs/docs/background-jobs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/background-jobs.md b/docs/docs/background-jobs.md index 78f64414b8ef..888768365227 100644 --- a/docs/docs/background-jobs.md +++ b/docs/docs/background-jobs.md @@ -1,6 +1,6 @@ # Background Jobs -No one likes waiting in line. This is especially true of your website: users don't want to wait for things to load that don't directly impact the task they're trying to accomplish. For example, sending a "welcome" email when a new user signs up. The process of sending the email could as long or longer than the sum total of everything else that happens during that request. Why make the user wait for it? As long as they eventually get the email, everything is good. +No one likes waiting in line. This is especially true of your website: users don't want to wait for things to load that don't directly impact the task they're trying to accomplish. For example, sending a "welcome" email when a new user signs up. The process of sending the email could take as long or longer than the sum total of everything else that happens during that request. Why make the user wait for it? As long as they eventually get the email, everything is good. A typical create-user flow could look something like this: From fccb82369d402ce8eeed742c4b8a558c0d7538f1 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Mon, 19 Aug 2024 18:46:05 +0100 Subject: [PATCH 225/258] TEMP: track dist --- .gitignore | 2 + .../adapters/BaseAdapter/BaseAdapter.d.ts | 64 +++++ .../adapters/BaseAdapter/BaseAdapter.d.ts.map | 1 + .../dist/adapters/BaseAdapter/BaseAdapter.js | 36 +++ .../adapters/PrismaAdapter/PrismaAdapter.d.ts | 71 ++++++ .../PrismaAdapter/PrismaAdapter.d.ts.map | 1 + .../adapters/PrismaAdapter/PrismaAdapter.js | 193 +++++++++++++++ .../dist/adapters/PrismaAdapter/errors.d.ts | 5 + .../adapters/PrismaAdapter/errors.d.ts.map | 1 + .../dist/adapters/PrismaAdapter/errors.js | 33 +++ packages/jobs/dist/bins/rw-jobs-worker.d.ts | 3 + .../jobs/dist/bins/rw-jobs-worker.d.ts.map | 1 + packages/jobs/dist/bins/rw-jobs-worker.js | 105 +++++++++ packages/jobs/dist/bins/rw-jobs.d.ts | 3 + packages/jobs/dist/bins/rw-jobs.d.ts.map | 1 + packages/jobs/dist/bins/rw-jobs.js | 223 ++++++++++++++++++ packages/jobs/dist/consts.d.ts | 26 ++ packages/jobs/dist/consts.d.ts.map | 1 + packages/jobs/dist/consts.js | 81 +++++++ packages/jobs/dist/core/Executor.d.ts | 30 +++ packages/jobs/dist/core/Executor.d.ts.map | 1 + packages/jobs/dist/core/Executor.js | 95 ++++++++ packages/jobs/dist/core/JobManager.d.ts | 19 ++ packages/jobs/dist/core/JobManager.d.ts.map | 1 + packages/jobs/dist/core/JobManager.js | 73 ++++++ packages/jobs/dist/core/Scheduler.d.ts | 23 ++ packages/jobs/dist/core/Scheduler.d.ts.map | 1 + packages/jobs/dist/core/Scheduler.js | 83 +++++++ packages/jobs/dist/core/Worker.d.ts | 39 +++ packages/jobs/dist/core/Worker.d.ts.map | 1 + packages/jobs/dist/core/Worker.js | 134 +++++++++++ packages/jobs/dist/errors.d.ts | 104 ++++++++ packages/jobs/dist/errors.d.ts.map | 1 + packages/jobs/dist/errors.js | 156 ++++++++++++ packages/jobs/dist/index.d.ts | 7 + packages/jobs/dist/index.d.ts.map | 1 + packages/jobs/dist/index.js | 43 ++++ packages/jobs/dist/loaders.d.ts | 13 + packages/jobs/dist/loaders.d.ts.map | 1 + packages/jobs/dist/loaders.js | 71 ++++++ packages/jobs/dist/types.d.ts | 152 ++++++++++++ packages/jobs/dist/types.d.ts.map | 1 + packages/jobs/dist/types.js | 16 ++ packages/jobs/dist/util.d.ts | 2 + packages/jobs/dist/util.d.ts.map | 1 + packages/jobs/dist/util.js | 31 +++ 46 files changed, 1951 insertions(+) create mode 100644 packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.d.ts create mode 100644 packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.d.ts.map create mode 100644 packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.js create mode 100644 packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts create mode 100644 packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts.map create mode 100644 packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.js create mode 100644 packages/jobs/dist/adapters/PrismaAdapter/errors.d.ts create mode 100644 packages/jobs/dist/adapters/PrismaAdapter/errors.d.ts.map create mode 100644 packages/jobs/dist/adapters/PrismaAdapter/errors.js create mode 100644 packages/jobs/dist/bins/rw-jobs-worker.d.ts create mode 100644 packages/jobs/dist/bins/rw-jobs-worker.d.ts.map create mode 100755 packages/jobs/dist/bins/rw-jobs-worker.js create mode 100644 packages/jobs/dist/bins/rw-jobs.d.ts create mode 100644 packages/jobs/dist/bins/rw-jobs.d.ts.map create mode 100755 packages/jobs/dist/bins/rw-jobs.js create mode 100644 packages/jobs/dist/consts.d.ts create mode 100644 packages/jobs/dist/consts.d.ts.map create mode 100644 packages/jobs/dist/consts.js create mode 100644 packages/jobs/dist/core/Executor.d.ts create mode 100644 packages/jobs/dist/core/Executor.d.ts.map create mode 100644 packages/jobs/dist/core/Executor.js create mode 100644 packages/jobs/dist/core/JobManager.d.ts create mode 100644 packages/jobs/dist/core/JobManager.d.ts.map create mode 100644 packages/jobs/dist/core/JobManager.js create mode 100644 packages/jobs/dist/core/Scheduler.d.ts create mode 100644 packages/jobs/dist/core/Scheduler.d.ts.map create mode 100644 packages/jobs/dist/core/Scheduler.js create mode 100644 packages/jobs/dist/core/Worker.d.ts create mode 100644 packages/jobs/dist/core/Worker.d.ts.map create mode 100644 packages/jobs/dist/core/Worker.js create mode 100644 packages/jobs/dist/errors.d.ts create mode 100644 packages/jobs/dist/errors.d.ts.map create mode 100644 packages/jobs/dist/errors.js create mode 100644 packages/jobs/dist/index.d.ts create mode 100644 packages/jobs/dist/index.d.ts.map create mode 100644 packages/jobs/dist/index.js create mode 100644 packages/jobs/dist/loaders.d.ts create mode 100644 packages/jobs/dist/loaders.d.ts.map create mode 100644 packages/jobs/dist/loaders.js create mode 100644 packages/jobs/dist/types.d.ts create mode 100644 packages/jobs/dist/types.d.ts.map create mode 100644 packages/jobs/dist/types.js create mode 100644 packages/jobs/dist/util.d.ts create mode 100644 packages/jobs/dist/util.d.ts.map create mode 100644 packages/jobs/dist/util.js diff --git a/.gitignore b/.gitignore index fc106d99ca1f..7acacd503f74 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ packages/create-redwood-app/create-redwood-app.tgz .nx/cache .nx/workspace-data + +!packages/jobs/dist diff --git a/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.d.ts b/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.d.ts new file mode 100644 index 000000000000..cd0979c822d0 --- /dev/null +++ b/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.d.ts @@ -0,0 +1,64 @@ +import type { BaseJob, BasicLogger, PossibleBaseJob } from '../../types'; +export interface SchedulePayload { + name: string; + path: string; + args: unknown[]; + runAt: Date; + queue: string; + priority: number; +} +export interface FindArgs { + processName: string; + maxRuntime: number; + queues: string[]; +} +export interface BaseAdapterOptions { + logger?: BasicLogger; +} +export interface SuccessOptions { + job: TJob; + deleteJob?: boolean; +} +export interface ErrorOptions { + job: TJob; + error: Error; +} +export interface FailureOptions { + job: TJob; + deleteJob?: boolean; +} +/** + * Base class for all job adapters. Provides a common interface for scheduling + * jobs. At a minimum, you must implement the `schedule` method in your adapter. + * + * Any object passed to the constructor is saved in `this.options` and should + * be used to configure your custom adapter. If `options.logger` is included + * you can access it via `this.logger` + */ +export declare abstract class BaseAdapter> { + options: TOptions; + logger: NonNullable; + constructor(options: TOptions); + abstract schedule(payload: SchedulePayload): TScheduleReturn; + /** + * Find a single job that's eligible to run with the given args + */ + abstract find(args: FindArgs): PossibleBaseJob | Promise; + /** + * Called when a job has successfully completed + */ + abstract success(options: SuccessOptions): void | Promise; + /** + * Called when an attempt to run a job produced an error + */ + abstract error(options: ErrorOptions): void | Promise; + /** + * Called when a job has errored more than maxAttempts and will not be retried + */ + abstract failure(options: FailureOptions): void | Promise; + /** + * Clear all jobs from storage + */ + abstract clear(): void | Promise; +} +//# sourceMappingURL=BaseAdapter.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.d.ts.map b/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.d.ts.map new file mode 100644 index 000000000000..d41f20c079f6 --- /dev/null +++ b/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"BaseAdapter.d.ts","sourceRoot":"","sources":["../../../src/adapters/BaseAdapter/BaseAdapter.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAGxE,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,OAAO,EAAE,CAAA;IACf,KAAK,EAAE,IAAI,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,QAAQ;IACvB,WAAW,EAAE,MAAM,CAAA;IACnB,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,EAAE,CAAA;CACjB;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,WAAW,CAAA;CACrB;AAED,MAAM,WAAW,cAAc,CAAC,IAAI,SAAS,OAAO,GAAG,OAAO;IAC5D,GAAG,EAAE,IAAI,CAAA;IACT,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAED,MAAM,WAAW,YAAY,CAAC,IAAI,SAAS,OAAO,GAAG,OAAO;IAC1D,GAAG,EAAE,IAAI,CAAA;IACT,KAAK,EAAE,KAAK,CAAA;CACb;AAED,MAAM,WAAW,cAAc,CAAC,IAAI,SAAS,OAAO,GAAG,OAAO;IAC5D,GAAG,EAAE,IAAI,CAAA;IACT,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAED;;;;;;;GAOG;AACH,8BAAsB,WAAW,CAC/B,QAAQ,SAAS,kBAAkB,GAAG,kBAAkB,EACxD,eAAe,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAEtC,OAAO,EAAE,QAAQ,CAAA;IACjB,MAAM,EAAE,WAAW,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAA;gBAE3B,OAAO,EAAE,QAAQ;IAU7B,QAAQ,CAAC,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,eAAe;IAE5D;;OAEG;IACH,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,GAAG,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC;IAEzE;;OAEG;IACH,QAAQ,CAAC,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAE/D;;OAEG;IACH,QAAQ,CAAC,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAE3D;;OAEG;IACH,QAAQ,CAAC,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAE/D;;OAEG;IACH,QAAQ,CAAC,KAAK,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;CACvC"} \ No newline at end of file diff --git a/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.js b/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.js new file mode 100644 index 000000000000..82b8d6db0efc --- /dev/null +++ b/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.js @@ -0,0 +1,36 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var BaseAdapter_exports = {}; +__export(BaseAdapter_exports, { + BaseAdapter: () => BaseAdapter +}); +module.exports = __toCommonJS(BaseAdapter_exports); +var import_consts = require("../../consts"); +class BaseAdapter { + options; + logger; + constructor(options) { + this.options = options; + this.logger = options?.logger ?? import_consts.DEFAULT_LOGGER; + } +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + BaseAdapter +}); diff --git a/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts b/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts new file mode 100644 index 000000000000..5e6c2d5f38ff --- /dev/null +++ b/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts @@ -0,0 +1,71 @@ +import type { PrismaClient } from '@prisma/client'; +import type { BaseJob } from '../../types'; +import type { BaseAdapterOptions, SchedulePayload, FindArgs, SuccessOptions, ErrorOptions, FailureOptions } from '../BaseAdapter/BaseAdapter'; +import { BaseAdapter } from '../BaseAdapter/BaseAdapter'; +export interface PrismaJob extends BaseJob { + id: number; + handler: string; + runAt: Date; + lockedAt: Date; + lockedBy: string; + lastError: string | null; + failedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} +export interface PrismaAdapterOptions extends BaseAdapterOptions { + /** + * An instance of PrismaClient which will be used to talk to the database + */ + db: PrismaClient; + /** + * The name of the model in the Prisma schema that represents the job table. + * @default 'BackgroundJob' + */ + model?: string; +} +/** + * Implements a job adapter using Prisma ORM. + * + * Assumes a table exists with the following schema (the table name can be customized): + * ```prisma + * model BackgroundJob { + * id Int \@id \@default(autoincrement()) + * attempts Int \@default(0) + * handler String + * queue String + * priority Int + * runAt DateTime + * lockedAt DateTime? + * lockedBy String? + * lastError String? + * failedAt DateTime? + * createdAt DateTime \@default(now()) + * updatedAt DateTime \@updatedAt + * } + * ``` + */ +export declare class PrismaAdapter extends BaseAdapter { + db: PrismaClient; + model: string; + accessor: PrismaClient[keyof PrismaClient]; + provider: string; + constructor(options: PrismaAdapterOptions); + /** + * Finds the next job to run, locking it so that no other process can pick it + * The act of locking a job is dependant on the DB server, so we'll run some + * raw SQL to do it in each case—Prisma doesn't provide enough flexibility + * in their generated code to do this in a DB-agnostic way. + * + * TODO: there may be more optimized versions of the locking queries in + * Postgres and MySQL + */ + find({ processName, maxRuntime, queues, }: FindArgs): Promise; + success({ job, deleteJob }: SuccessOptions): Promise; + error({ job, error }: ErrorOptions): Promise; + failure({ job, deleteJob }: FailureOptions): Promise; + schedule({ name, path, args, runAt, queue, priority, }: SchedulePayload): Promise; + clear(): Promise; + backoffMilliseconds(attempts: number): number; +} +//# sourceMappingURL=PrismaAdapter.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts.map b/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts.map new file mode 100644 index 000000000000..86b12f34754f --- /dev/null +++ b/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"PrismaAdapter.d.ts","sourceRoot":"","sources":["../../../src/adapters/PrismaAdapter/PrismaAdapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAIlD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAA;AAC1C,OAAO,KAAK,EACV,kBAAkB,EAClB,eAAe,EACf,QAAQ,EACR,cAAc,EACd,YAAY,EACZ,cAAc,EACf,MAAM,4BAA4B,CAAA;AACnC,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAA;AAIxD,MAAM,WAAW,SAAU,SAAQ,OAAO;IACxC,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,IAAI,CAAA;IACX,QAAQ,EAAE,IAAI,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,QAAQ,EAAE,IAAI,GAAG,IAAI,CAAA;IACrB,SAAS,EAAE,IAAI,CAAA;IACf,SAAS,EAAE,IAAI,CAAA;CAChB;AAED,MAAM,WAAW,oBAAqB,SAAQ,kBAAkB;IAC9D;;OAEG;IACH,EAAE,EAAE,YAAY,CAAA;IAEhB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAUD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,aAAc,SAAQ,WAAW,CAAC,oBAAoB,CAAC;IAClE,EAAE,EAAE,YAAY,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,YAAY,CAAC,MAAM,YAAY,CAAC,CAAA;IAC1C,QAAQ,EAAE,MAAM,CAAA;gBAEJ,OAAO,EAAE,oBAAoB;IAqBzC;;;;;;;;OAQG;IACY,IAAI,CAAC,EAClB,WAAW,EACX,UAAU,EACV,MAAM,GACP,EAAE,QAAQ,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IA0F7B,OAAO,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,EAAE,cAAc,CAAC,SAAS,CAAC;IAkBrD,KAAK,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE,YAAY,CAAC,SAAS,CAAC;IAqB7C,OAAO,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,EAAE,cAAc,CAAC,SAAS,CAAC;IAYrD,QAAQ,CAAC,EACtB,IAAI,EACJ,IAAI,EACJ,IAAI,EACJ,KAAK,EACL,KAAK,EACL,QAAQ,GACT,EAAE,eAAe;IAWH,KAAK;IAIpB,mBAAmB,CAAC,QAAQ,EAAE,MAAM;CAGrC"} \ No newline at end of file diff --git a/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.js b/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.js new file mode 100644 index 000000000000..ea77b8917b6b --- /dev/null +++ b/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.js @@ -0,0 +1,193 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var PrismaAdapter_exports = {}; +__export(PrismaAdapter_exports, { + PrismaAdapter: () => PrismaAdapter +}); +module.exports = __toCommonJS(PrismaAdapter_exports); +var import_change_case = require("change-case"); +var import_consts = require("../../consts"); +var import_BaseAdapter = require("../BaseAdapter/BaseAdapter"); +var import_errors = require("./errors"); +class PrismaAdapter extends import_BaseAdapter.BaseAdapter { + db; + model; + accessor; + provider; + constructor(options) { + super(options); + this.db = options.db; + this.model = options.model || import_consts.DEFAULT_MODEL_NAME; + this.accessor = this.db[(0, import_change_case.camelCase)(this.model)]; + this.provider = options.db._activeProvider; + if (!this.accessor) { + throw new import_errors.ModelNameError(this.model); + } + } + /** + * Finds the next job to run, locking it so that no other process can pick it + * The act of locking a job is dependant on the DB server, so we'll run some + * raw SQL to do it in each case—Prisma doesn't provide enough flexibility + * in their generated code to do this in a DB-agnostic way. + * + * TODO: there may be more optimized versions of the locking queries in + * Postgres and MySQL + */ + async find({ + processName, + maxRuntime, + queues + }) { + const maxRuntimeExpire = new Date( + (/* @__PURE__ */ new Date()).getTime() + (maxRuntime || import_consts.DEFAULT_MAX_RUNTIME * 1e3) + ); + const where = { + AND: [ + { + OR: [ + { + AND: [ + { runAt: { lte: /* @__PURE__ */ new Date() } }, + { + OR: [ + { lockedAt: null }, + { + lockedAt: { + lt: maxRuntimeExpire + } + } + ] + } + ] + }, + { lockedBy: processName } + ] + }, + { failedAt: null } + ] + }; + const whereWithQueue = where; + if (queues.length > 1 || queues[0] !== "*") { + Object.assign(whereWithQueue, { + AND: [...where.AND, { queue: { in: queues } }] + }); + } + const job = await this.accessor.findFirst({ + select: { id: true, attempts: true }, + where: whereWithQueue, + orderBy: [{ priority: "asc" }, { runAt: "asc" }], + take: 1 + }); + if (job) { + const whereWithQueueAndId = Object.assign(whereWithQueue, { + AND: [...whereWithQueue.AND, { id: job.id }] + }); + const { count } = await this.accessor.updateMany({ + where: whereWithQueueAndId, + data: { + lockedAt: /* @__PURE__ */ new Date(), + lockedBy: processName, + attempts: job.attempts + 1 + } + }); + if (count) { + const data = await this.accessor.findFirst({ where: { id: job.id } }); + const { name, path, args } = JSON.parse(data.handler); + return { ...data, name, path, args }; + } + } + return void 0; + } + // Prisma queries are lazily evaluated and only sent to the db when they are + // awaited, so do the await here to ensure they actually run (if the user + // doesn't await the Promise then the queries will never be executed!) + async success({ job, deleteJob }) { + this.logger.debug(`[RedwoodJob] Job ${job.id} success`); + if (deleteJob) { + await this.accessor.delete({ where: { id: job.id } }); + } else { + await this.accessor.update({ + where: { id: job.id }, + data: { + lockedAt: null, + lockedBy: null, + lastError: null, + runAt: null + } + }); + } + } + async error({ job, error }) { + this.logger.debug(`[RedwoodJob] Job ${job.id} failure`); + const data = { + lockedAt: null, + lockedBy: null, + lastError: `${error.message} + +${error.stack}`, + runAt: null + }; + data.runAt = new Date( + (/* @__PURE__ */ new Date()).getTime() + this.backoffMilliseconds(job.attempts) + ); + await this.accessor.update({ + where: { id: job.id }, + data + }); + } + // Job has had too many attempts, it has now permanently failed. + async failure({ job, deleteJob }) { + if (deleteJob) { + await this.accessor.delete({ where: { id: job.id } }); + } else { + await this.accessor.update({ + where: { id: job.id }, + data: { failedAt: /* @__PURE__ */ new Date() } + }); + } + } + // Schedules a job by creating a new record in the background job table + async schedule({ + name, + path, + args, + runAt, + queue, + priority + }) { + await this.accessor.create({ + data: { + handler: JSON.stringify({ name, path, args }), + runAt, + queue, + priority + } + }); + } + async clear() { + await this.accessor.deleteMany(); + } + backoffMilliseconds(attempts) { + return 1e3 * attempts ** 4; + } +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + PrismaAdapter +}); diff --git a/packages/jobs/dist/adapters/PrismaAdapter/errors.d.ts b/packages/jobs/dist/adapters/PrismaAdapter/errors.d.ts new file mode 100644 index 000000000000..4f9371334d3a --- /dev/null +++ b/packages/jobs/dist/adapters/PrismaAdapter/errors.d.ts @@ -0,0 +1,5 @@ +import { RedwoodJobError } from '../../errors'; +export declare class ModelNameError extends RedwoodJobError { + constructor(name: string); +} +//# sourceMappingURL=errors.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/adapters/PrismaAdapter/errors.d.ts.map b/packages/jobs/dist/adapters/PrismaAdapter/errors.d.ts.map new file mode 100644 index 000000000000..3db8b33f981e --- /dev/null +++ b/packages/jobs/dist/adapters/PrismaAdapter/errors.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../../src/adapters/PrismaAdapter/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAG9C,qBAAa,cAAe,SAAQ,eAAe;gBACrC,IAAI,EAAE,MAAM;CAGzB"} \ No newline at end of file diff --git a/packages/jobs/dist/adapters/PrismaAdapter/errors.js b/packages/jobs/dist/adapters/PrismaAdapter/errors.js new file mode 100644 index 000000000000..2d7968a7cbff --- /dev/null +++ b/packages/jobs/dist/adapters/PrismaAdapter/errors.js @@ -0,0 +1,33 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var errors_exports = {}; +__export(errors_exports, { + ModelNameError: () => ModelNameError +}); +module.exports = __toCommonJS(errors_exports); +var import_errors = require("../../errors"); +class ModelNameError extends import_errors.RedwoodJobError { + constructor(name) { + super(`Model \`${name}\` not found in PrismaClient`); + } +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + ModelNameError +}); diff --git a/packages/jobs/dist/bins/rw-jobs-worker.d.ts b/packages/jobs/dist/bins/rw-jobs-worker.d.ts new file mode 100644 index 000000000000..571ae7b230ab --- /dev/null +++ b/packages/jobs/dist/bins/rw-jobs-worker.d.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node +export {}; +//# sourceMappingURL=rw-jobs-worker.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/bins/rw-jobs-worker.d.ts.map b/packages/jobs/dist/bins/rw-jobs-worker.d.ts.map new file mode 100644 index 000000000000..b1a7d0908b41 --- /dev/null +++ b/packages/jobs/dist/bins/rw-jobs-worker.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"rw-jobs-worker.d.ts","sourceRoot":"","sources":["../../src/bins/rw-jobs-worker.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/packages/jobs/dist/bins/rw-jobs-worker.js b/packages/jobs/dist/bins/rw-jobs-worker.js new file mode 100755 index 000000000000..a7fd62fb855c --- /dev/null +++ b/packages/jobs/dist/bins/rw-jobs-worker.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var import_node_console = __toESM(require("node:console")); +var import_node_process = __toESM(require("node:process")); +var import_helpers = require("yargs/helpers"); +var import_yargs = __toESM(require("yargs/yargs")); +var import_loadEnvFiles = require("@redwoodjs/cli-helpers/loadEnvFiles"); +var import_consts = require("../consts"); +var import_errors = require("../errors"); +var import_loaders = require("../loaders"); +(0, import_loadEnvFiles.loadEnvFiles)(); +const parseArgs = (argv) => { + return (0, import_yargs.default)((0, import_helpers.hideBin)(argv)).usage( + "Starts a single RedwoodJob worker to process background jobs\n\nUsage: $0 [options]" + ).option("index", { + type: "number", + description: "The index of the `workers` array from the exported `jobs` config to use to configure this worker", + default: 0 + }).option("id", { + type: "number", + description: "The worker count id to identify this worker. ie: if you had `count: 2` in your worker config, you would have two workers with ids 0 and 1", + default: 0 + }).option("workoff", { + type: "boolean", + default: false, + description: "Work off all jobs in the queue(s) and exit" + }).option("clear", { + type: "boolean", + default: false, + description: "Remove all jobs in all queues and exit" + }).help().argv; +}; +const setProcessTitle = ({ + id, + queue +}) => { + import_node_process.default.title = `${import_consts.PROCESS_TITLE_PREFIX}.${[queue].flat().join("-")}.${id}`; +}; +const setupSignals = ({ + worker, + logger +}) => { + import_node_process.default.on("SIGINT", () => { + logger.warn( + `[${import_node_process.default.title}] SIGINT received at ${(/* @__PURE__ */ new Date()).toISOString()}, finishing work...` + ); + worker.forever = false; + }); + import_node_process.default.on("SIGTERM", () => { + logger.warn( + `[${import_node_process.default.title}] SIGTERM received at ${(/* @__PURE__ */ new Date()).toISOString()}, exiting now!` + ); + import_node_process.default.exit(0); + }); +}; +const main = async () => { + const { index, id, clear, workoff } = await parseArgs(import_node_process.default.argv); + let manager; + try { + manager = await (0, import_loaders.loadJobsManager)(); + } catch (e) { + import_node_console.default.error(e); + import_node_process.default.exit(1); + } + const workerConfig = manager.workers[index]; + if (!workerConfig) { + throw new import_errors.WorkerConfigIndexNotFoundError(index); + } + const logger = workerConfig.logger ?? manager.logger ?? import_consts.DEFAULT_LOGGER; + logger.warn( + `[${import_node_process.default.title}] Starting work at ${(/* @__PURE__ */ new Date()).toISOString()}...` + ); + setProcessTitle({ id, queue: workerConfig.queue }); + const worker = manager.createWorker({ index, clear, workoff }); + worker.run().then(() => { + logger.info(`[${import_node_process.default.title}] Worker finished, shutting down.`); + import_node_process.default.exit(0); + }); + setupSignals({ worker, logger }); +}; +if (import_node_process.default.env.NODE_ENV !== "test") { + main(); +} diff --git a/packages/jobs/dist/bins/rw-jobs.d.ts b/packages/jobs/dist/bins/rw-jobs.d.ts new file mode 100644 index 000000000000..17bc3f9f2d7f --- /dev/null +++ b/packages/jobs/dist/bins/rw-jobs.d.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node +export type NumWorkersConfig = [number, number][]; +//# sourceMappingURL=rw-jobs.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/bins/rw-jobs.d.ts.map b/packages/jobs/dist/bins/rw-jobs.d.ts.map new file mode 100644 index 000000000000..b3c025b1d7a2 --- /dev/null +++ b/packages/jobs/dist/bins/rw-jobs.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"rw-jobs.d.ts","sourceRoot":"","sources":["../../src/bins/rw-jobs.ts"],"names":[],"mappings":";AAqBA,MAAM,MAAM,gBAAgB,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAA"} \ No newline at end of file diff --git a/packages/jobs/dist/bins/rw-jobs.js b/packages/jobs/dist/bins/rw-jobs.js new file mode 100755 index 000000000000..a93609059424 --- /dev/null +++ b/packages/jobs/dist/bins/rw-jobs.js @@ -0,0 +1,223 @@ +#!/usr/bin/env node +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var rw_jobs_exports = {}; +module.exports = __toCommonJS(rw_jobs_exports); +var import_node_child_process = require("node:child_process"); +var import_node_console = __toESM(require("node:console")); +var import_node_path = __toESM(require("node:path")); +var import_node_process = __toESM(require("node:process")); +var import_node_timers = require("node:timers"); +var import_helpers = require("yargs/helpers"); +var import_yargs = __toESM(require("yargs/yargs")); +var import_loadEnvFiles = require("@redwoodjs/cli-helpers/loadEnvFiles"); +var import_consts = require("../consts"); +var import_loaders = require("../loaders"); +(0, import_loadEnvFiles.loadEnvFiles)(); +import_node_process.default.title = "rw-jobs"; +const parseArgs = (argv) => { + const parsed = (0, import_yargs.default)((0, import_helpers.hideBin)(argv)).usage( + "Starts the RedwoodJob runner to process background jobs\n\nUsage: $0 [options]" + ).command("work", "Start a worker and process jobs").command("workoff", "Start a worker and exit after all jobs processed").command("start", "Start workers in daemon mode").command("stop", "Stop any daemonized job workers").command("restart", "Stop and start any daemonized job workers").command("clear", "Clear the job queue").demandCommand(1, "You must specify a mode to start in").example( + "$0 start -n 2", + "Start the job runner with 2 workers in daemon mode" + ).example( + "$0 start -n default:2,email:1", + 'Start the job runner in daemon mode with 2 workers for the "default" queue and 1 for the "email" queue' + ).help().argv; + return { command: parsed._[0] }; +}; +const buildNumWorkers = (config) => { + const workers = []; + config.map((worker, index) => { + for (let id = 0; id < worker.count; id++) { + workers.push([index, id]); + } + }); + return workers; +}; +const startWorkers = ({ + numWorkers, + detach = false, + workoff = false, + logger +}) => { + logger.warn(`Starting ${numWorkers.length} worker(s)...`); + return numWorkers.map(([index, id]) => { + const workerArgs = []; + workerArgs.push("--index", index.toString()); + workerArgs.push("--id", id.toString()); + if (workoff) { + workerArgs.push("--workoff"); + } + const worker = (0, import_node_child_process.fork)(import_node_path.default.join(__dirname, "rw-jobs-worker.js"), workerArgs, { + detached: detach, + stdio: detach ? "ignore" : "inherit", + env: import_node_process.default.env + }); + if (detach) { + worker.unref(); + } else { + worker.on("exit", (_code) => { + }); + } + return worker; + }); +}; +const stopWorkers = async ({ + numWorkers, + signal = "SIGINT", + logger +}) => { + logger.warn( + `Stopping ${numWorkers.length} worker(s) gracefully (${signal})...` + ); + const processIds = await findWorkerProcesses(); + if (processIds.length === 0) { + logger.warn(`No running workers found.`); + return; + } + for (const processId of processIds) { + logger.info(`Stopping process id ${processId}...`); + import_node_process.default.kill(processId, signal); + while ((await findWorkerProcesses(processId)).length) { + await new Promise((resolve) => (0, import_node_timers.setTimeout)(resolve, 250)); + } + } +}; +const clearQueue = ({ logger }) => { + logger.warn(`Starting worker to clear job queue...`); + (0, import_node_child_process.fork)(import_node_path.default.join(__dirname, "rw-jobs-worker.js"), ["--clear"]); +}; +const signalSetup = ({ + workers, + logger +}) => { + let sigtermCount = 0; + import_node_process.default.on("SIGINT", () => { + sigtermCount++; + let message = "SIGINT received: shutting down workers gracefully (press Ctrl-C again to exit immediately)..."; + if (sigtermCount > 1) { + message = "SIGINT received again, exiting immediately..."; + } + logger.info(message); + workers.forEach((worker) => { + if (sigtermCount > 1) { + worker.kill("SIGTERM"); + } else { + worker.kill("SIGINT"); + } + }); + }); +}; +const findWorkerProcesses = async (id) => { + return new Promise(function(resolve, reject) { + const plat = import_node_process.default.platform; + const cmd = plat === "win32" ? "tasklist" : plat === "darwin" ? "ps -ax | grep " + import_consts.PROCESS_TITLE_PREFIX : plat === "linux" ? "ps -A" : ""; + if (cmd === "") { + resolve([]); + } + (0, import_node_child_process.exec)(cmd, function(err, stdout) { + if (err) { + reject(err); + } + const list = stdout.trim().split("\n"); + const matches = list.filter((line) => { + if (plat == "darwin" || plat == "linux") { + return !line.match("grep"); + } + return true; + }); + if (matches.length === 0) { + resolve([]); + } + const pids = matches.map((line) => parseInt(line.split(" ")[0])); + if (id) { + resolve(pids.filter((pid) => pid === id)); + } else { + resolve(pids); + } + }); + }); +}; +const main = async () => { + const { command } = parseArgs(import_node_process.default.argv); + let jobsConfig; + try { + jobsConfig = await (0, import_loaders.loadJobsManager)(); + } catch (e) { + import_node_console.default.error(e); + import_node_process.default.exit(1); + } + const workerConfig = jobsConfig.workers; + const numWorkers = buildNumWorkers(workerConfig); + const logger = jobsConfig.logger ?? import_consts.DEFAULT_LOGGER; + logger.warn(`Starting RedwoodJob Runner at ${(/* @__PURE__ */ new Date()).toISOString()}...`); + switch (command) { + case "start": + startWorkers({ + numWorkers, + detach: true, + logger + }); + return import_node_process.default.exit(0); + case "restart": + await stopWorkers({ numWorkers, signal: "SIGINT", logger }); + startWorkers({ + numWorkers, + detach: true, + logger + }); + return import_node_process.default.exit(0); + case "work": + return signalSetup({ + workers: startWorkers({ + numWorkers, + logger + }), + logger + }); + case "workoff": + return signalSetup({ + workers: startWorkers({ + numWorkers, + workoff: true, + logger + }), + logger + }); + case "stop": + return await stopWorkers({ + numWorkers, + signal: "SIGINT", + logger + }); + case "clear": + return clearQueue({ logger }); + } +}; +if (import_node_process.default.env.NODE_ENV !== "test") { + main(); +} diff --git a/packages/jobs/dist/consts.d.ts b/packages/jobs/dist/consts.d.ts new file mode 100644 index 000000000000..c2c722cd2870 --- /dev/null +++ b/packages/jobs/dist/consts.d.ts @@ -0,0 +1,26 @@ +export declare const DEFAULT_MAX_ATTEMPTS = 24; +/** 4 hours in seconds */ +export declare const DEFAULT_MAX_RUNTIME = 14400; +/** 5 seconds */ +export declare const DEFAULT_SLEEP_DELAY = 5; +export declare const DEFAULT_DELETE_SUCCESSFUL_JOBS = true; +export declare const DEFAULT_DELETE_FAILED_JOBS = false; +export declare const DEFAULT_LOGGER: Console; +export declare const DEFAULT_QUEUE = "default"; +export declare const DEFAULT_WORK_QUEUE = "*"; +export declare const DEFAULT_PRIORITY = 50; +export declare const DEFAULT_WAIT = 0; +export declare const DEFAULT_WAIT_UNTIL: null; +export declare const PROCESS_TITLE_PREFIX = "rw-jobs-worker"; +export declare const DEFAULT_MODEL_NAME = "BackgroundJob"; +/** + * The name of the exported variable from the jobs config file that contains + * the adapter + */ +export declare const DEFAULT_ADAPTER_NAME = "adapter"; +/** + * The name of the exported variable from the jobs config file that contains + * the logger + */ +export declare const DEFAULT_LOGGER_NAME = "logger"; +//# sourceMappingURL=consts.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/consts.d.ts.map b/packages/jobs/dist/consts.d.ts.map new file mode 100644 index 000000000000..c679068cbcd6 --- /dev/null +++ b/packages/jobs/dist/consts.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,oBAAoB,KAAK,CAAA;AACtC,yBAAyB;AACzB,eAAO,MAAM,mBAAmB,QAAS,CAAA;AACzC,gBAAgB;AAChB,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAEpC,eAAO,MAAM,8BAA8B,OAAO,CAAA;AAClD,eAAO,MAAM,0BAA0B,QAAQ,CAAA;AAC/C,eAAO,MAAM,cAAc,SAAU,CAAA;AACrC,eAAO,MAAM,aAAa,YAAY,CAAA;AACtC,eAAO,MAAM,kBAAkB,MAAM,CAAA;AACrC,eAAO,MAAM,gBAAgB,KAAK,CAAA;AAClC,eAAO,MAAM,YAAY,IAAI,CAAA;AAC7B,eAAO,MAAM,kBAAkB,MAAO,CAAA;AACtC,eAAO,MAAM,oBAAoB,mBAAmB,CAAA;AACpD,eAAO,MAAM,kBAAkB,kBAAkB,CAAA;AAEjD;;;GAGG;AACH,eAAO,MAAM,oBAAoB,YAAY,CAAA;AAC7C;;;GAGG;AACH,eAAO,MAAM,mBAAmB,WAAW,CAAA"} \ No newline at end of file diff --git a/packages/jobs/dist/consts.js b/packages/jobs/dist/consts.js new file mode 100644 index 000000000000..483f83772caa --- /dev/null +++ b/packages/jobs/dist/consts.js @@ -0,0 +1,81 @@ +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var consts_exports = {}; +__export(consts_exports, { + DEFAULT_ADAPTER_NAME: () => DEFAULT_ADAPTER_NAME, + DEFAULT_DELETE_FAILED_JOBS: () => DEFAULT_DELETE_FAILED_JOBS, + DEFAULT_DELETE_SUCCESSFUL_JOBS: () => DEFAULT_DELETE_SUCCESSFUL_JOBS, + DEFAULT_LOGGER: () => DEFAULT_LOGGER, + DEFAULT_LOGGER_NAME: () => DEFAULT_LOGGER_NAME, + DEFAULT_MAX_ATTEMPTS: () => DEFAULT_MAX_ATTEMPTS, + DEFAULT_MAX_RUNTIME: () => DEFAULT_MAX_RUNTIME, + DEFAULT_MODEL_NAME: () => DEFAULT_MODEL_NAME, + DEFAULT_PRIORITY: () => DEFAULT_PRIORITY, + DEFAULT_QUEUE: () => DEFAULT_QUEUE, + DEFAULT_SLEEP_DELAY: () => DEFAULT_SLEEP_DELAY, + DEFAULT_WAIT: () => DEFAULT_WAIT, + DEFAULT_WAIT_UNTIL: () => DEFAULT_WAIT_UNTIL, + DEFAULT_WORK_QUEUE: () => DEFAULT_WORK_QUEUE, + PROCESS_TITLE_PREFIX: () => PROCESS_TITLE_PREFIX +}); +module.exports = __toCommonJS(consts_exports); +var import_node_console = __toESM(require("node:console")); +const DEFAULT_MAX_ATTEMPTS = 24; +const DEFAULT_MAX_RUNTIME = 14400; +const DEFAULT_SLEEP_DELAY = 5; +const DEFAULT_DELETE_SUCCESSFUL_JOBS = true; +const DEFAULT_DELETE_FAILED_JOBS = false; +const DEFAULT_LOGGER = import_node_console.default; +const DEFAULT_QUEUE = "default"; +const DEFAULT_WORK_QUEUE = "*"; +const DEFAULT_PRIORITY = 50; +const DEFAULT_WAIT = 0; +const DEFAULT_WAIT_UNTIL = null; +const PROCESS_TITLE_PREFIX = "rw-jobs-worker"; +const DEFAULT_MODEL_NAME = "BackgroundJob"; +const DEFAULT_ADAPTER_NAME = "adapter"; +const DEFAULT_LOGGER_NAME = "logger"; +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + DEFAULT_ADAPTER_NAME, + DEFAULT_DELETE_FAILED_JOBS, + DEFAULT_DELETE_SUCCESSFUL_JOBS, + DEFAULT_LOGGER, + DEFAULT_LOGGER_NAME, + DEFAULT_MAX_ATTEMPTS, + DEFAULT_MAX_RUNTIME, + DEFAULT_MODEL_NAME, + DEFAULT_PRIORITY, + DEFAULT_QUEUE, + DEFAULT_SLEEP_DELAY, + DEFAULT_WAIT, + DEFAULT_WAIT_UNTIL, + DEFAULT_WORK_QUEUE, + PROCESS_TITLE_PREFIX +}); diff --git a/packages/jobs/dist/core/Executor.d.ts b/packages/jobs/dist/core/Executor.d.ts new file mode 100644 index 000000000000..c6fbf4d417de --- /dev/null +++ b/packages/jobs/dist/core/Executor.d.ts @@ -0,0 +1,30 @@ +import type { BaseAdapter } from '../adapters/BaseAdapter/BaseAdapter'; +import type { BaseJob, BasicLogger } from '../types'; +interface Options { + adapter: BaseAdapter; + job: BaseJob; + logger?: BasicLogger; + maxAttempts?: number; + deleteFailedJobs?: boolean; + deleteSuccessfulJobs?: boolean; +} +export declare const DEFAULTS: { + logger: Console; + maxAttempts: number; + deleteFailedJobs: boolean; + deleteSuccessfulJobs: boolean; +}; +export declare class Executor { + options: Required; + adapter: Options['adapter']; + logger: NonNullable; + job: BaseJob; + maxAttempts: NonNullable; + deleteFailedJobs: NonNullable; + deleteSuccessfulJobs: NonNullable; + constructor(options: Options); + get jobIdentifier(): string; + perform(): Promise; +} +export {}; +//# sourceMappingURL=Executor.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/core/Executor.d.ts.map b/packages/jobs/dist/core/Executor.d.ts.map new file mode 100644 index 000000000000..b9247747b741 --- /dev/null +++ b/packages/jobs/dist/core/Executor.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"Executor.d.ts","sourceRoot":"","sources":["../../src/core/Executor.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qCAAqC,CAAA;AAStE,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;AAEpD,UAAU,OAAO;IACf,OAAO,EAAE,WAAW,CAAA;IACpB,GAAG,EAAE,OAAO,CAAA;IACZ,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B,oBAAoB,CAAC,EAAE,OAAO,CAAA;CAC/B;AAED,eAAO,MAAM,QAAQ;;;;;CAKpB,CAAA;AAED,qBAAa,QAAQ;IACnB,OAAO,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;IAC1B,OAAO,EAAE,OAAO,CAAC,SAAS,CAAC,CAAA;IAC3B,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAA;IACtC,GAAG,EAAE,OAAO,CAAA;IACZ,WAAW,EAAE,WAAW,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,CAAA;IAChD,gBAAgB,EAAE,WAAW,CAAC,OAAO,CAAC,kBAAkB,CAAC,CAAC,CAAA;IAC1D,oBAAoB,EAAE,WAAW,CAAC,OAAO,CAAC,sBAAsB,CAAC,CAAC,CAAA;gBAEtD,OAAO,EAAE,OAAO;IAmB5B,IAAI,aAAa,WAEhB;IAEK,OAAO;CAkCd"} \ No newline at end of file diff --git a/packages/jobs/dist/core/Executor.js b/packages/jobs/dist/core/Executor.js new file mode 100644 index 000000000000..5efccc097857 --- /dev/null +++ b/packages/jobs/dist/core/Executor.js @@ -0,0 +1,95 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var Executor_exports = {}; +__export(Executor_exports, { + DEFAULTS: () => DEFAULTS, + Executor: () => Executor +}); +module.exports = __toCommonJS(Executor_exports); +var import_consts = require("../consts"); +var import_errors = require("../errors"); +var import_loaders = require("../loaders"); +const DEFAULTS = { + logger: import_consts.DEFAULT_LOGGER, + maxAttempts: import_consts.DEFAULT_MAX_ATTEMPTS, + deleteFailedJobs: import_consts.DEFAULT_DELETE_FAILED_JOBS, + deleteSuccessfulJobs: import_consts.DEFAULT_DELETE_SUCCESSFUL_JOBS +}; +class Executor { + options; + adapter; + logger; + job; + maxAttempts; + deleteFailedJobs; + deleteSuccessfulJobs; + constructor(options) { + this.options = { ...DEFAULTS, ...options }; + if (!this.options.adapter) { + throw new import_errors.AdapterRequiredError(); + } + if (!this.options.job) { + throw new import_errors.JobRequiredError(); + } + this.adapter = this.options.adapter; + this.logger = this.options.logger; + this.job = this.options.job; + this.maxAttempts = this.options.maxAttempts; + this.deleteFailedJobs = this.options.deleteFailedJobs; + this.deleteSuccessfulJobs = this.options.deleteSuccessfulJobs; + } + get jobIdentifier() { + return `${this.job.id} (${this.job.path}:${this.job.name})`; + } + async perform() { + this.logger.info(`[RedwoodJob] Started job ${this.jobIdentifier}`); + try { + const job = await (0, import_loaders.loadJob)({ name: this.job.name, path: this.job.path }); + await job.perform(...this.job.args); + await this.adapter.success({ + job: this.job, + deleteJob: import_consts.DEFAULT_DELETE_SUCCESSFUL_JOBS + }); + } catch (error) { + this.logger.error( + `[RedwoodJob] Error in job ${this.jobIdentifier}: ${error.message}` + ); + this.logger.error(error.stack); + await this.adapter.error({ + job: this.job, + error + }); + if (this.job.attempts >= this.maxAttempts) { + this.logger.warn( + this.job, + `[RedwoodJob] Failed job ${this.jobIdentifier}: reached max attempts (${this.maxAttempts})` + ); + await this.adapter.failure({ + job: this.job, + deleteJob: this.deleteFailedJobs + }); + } + } + } +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + DEFAULTS, + Executor +}); diff --git a/packages/jobs/dist/core/JobManager.d.ts b/packages/jobs/dist/core/JobManager.d.ts new file mode 100644 index 000000000000..cab19efd396c --- /dev/null +++ b/packages/jobs/dist/core/JobManager.d.ts @@ -0,0 +1,19 @@ +import type { Adapters, BasicLogger, CreateSchedulerConfig, Job, JobDefinition, JobManagerConfig, ScheduleJobOptions, WorkerConfig } from '../types'; +import type { WorkerOptions } from './Worker'; +import { Worker } from './Worker'; +export interface CreateWorkerArgs { + index: number; + workoff: WorkerOptions['workoff']; + clear: WorkerOptions['clear']; +} +export declare class JobManager { + adapters: TAdapters; + queues: TQueues; + logger: TLogger; + workers: WorkerConfig[]; + constructor(config: JobManagerConfig); + createScheduler(schedulerConfig: CreateSchedulerConfig): >(job: T, jobArgs?: Parameters, jobOptions?: ScheduleJobOptions) => Promise; + createJob(jobDefinition: JobDefinition): Job; + createWorker({ index, workoff, clear }: CreateWorkerArgs): Worker; +} +//# sourceMappingURL=JobManager.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/core/JobManager.d.ts.map b/packages/jobs/dist/core/JobManager.d.ts.map new file mode 100644 index 000000000000..bdf9c76d6ed0 --- /dev/null +++ b/packages/jobs/dist/core/JobManager.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"JobManager.d.ts","sourceRoot":"","sources":["../../src/core/JobManager.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,QAAQ,EACR,WAAW,EACX,qBAAqB,EACrB,GAAG,EACH,aAAa,EACb,gBAAgB,EAChB,kBAAkB,EAClB,YAAY,EACb,MAAM,UAAU,CAAA;AAGjB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAC7C,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAEjC,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,aAAa,CAAC,SAAS,CAAC,CAAA;IACjC,KAAK,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;CAC9B;AAED,qBAAa,UAAU,CACrB,SAAS,SAAS,QAAQ,EAC1B,OAAO,SAAS,MAAM,EAAE,EACxB,OAAO,SAAS,WAAW;IAE3B,QAAQ,EAAE,SAAS,CAAA;IACnB,MAAM,EAAE,OAAO,CAAA;IACf,MAAM,EAAE,OAAO,CAAA;IACf,OAAO,EAAE,YAAY,CAAC,SAAS,EAAE,OAAO,CAAC,EAAE,CAAA;gBAE/B,MAAM,EAAE,gBAAgB,CAAC,SAAS,EAAE,OAAO,EAAE,OAAO,CAAC;IAOjE,eAAe,CAAC,eAAe,EAAE,qBAAqB,CAAC,SAAS,CAAC,IAMvD,CAAC,SAAS,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAC9B,CAAC,YACI,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,eACrB,kBAAkB;IAMnC,SAAS,CAAC,KAAK,SAAS,OAAO,EAAE,EAC/B,aAAa,EAAE,aAAa,CAAC,OAAO,EAAE,KAAK,CAAC,GAC3C,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC;IAOtB,YAAY,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE,gBAAgB;CAoBzD"} \ No newline at end of file diff --git a/packages/jobs/dist/core/JobManager.js b/packages/jobs/dist/core/JobManager.js new file mode 100644 index 000000000000..61d178d5bad7 --- /dev/null +++ b/packages/jobs/dist/core/JobManager.js @@ -0,0 +1,73 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var JobManager_exports = {}; +__export(JobManager_exports, { + JobManager: () => JobManager +}); +module.exports = __toCommonJS(JobManager_exports); +var import_errors = require("../errors"); +var import_Scheduler = require("./Scheduler"); +var import_Worker = require("./Worker"); +class JobManager { + adapters; + queues; + logger; + workers; + constructor(config) { + this.adapters = config.adapters; + this.queues = config.queues; + this.logger = config.logger; + this.workers = config.workers; + } + createScheduler(schedulerConfig) { + const scheduler = new import_Scheduler.Scheduler({ + adapter: this.adapters[schedulerConfig.adapter], + logger: this.logger + }); + return (job, jobArgs, jobOptions) => { + return scheduler.schedule({ job, jobArgs, jobOptions }); + }; + } + createJob(jobDefinition) { + return jobDefinition; + } + createWorker({ index, workoff, clear }) { + const config = this.workers[index]; + const adapter = this.adapters[config.adapter]; + if (!adapter) { + throw new import_errors.AdapterNotFoundError(config.adapter.toString()); + } + return new import_Worker.Worker({ + adapter: this.adapters[config.adapter], + logger: config.logger || this.logger, + maxAttempts: config.maxAttempts, + maxRuntime: config.maxRuntime, + sleepDelay: config.sleepDelay, + deleteFailedJobs: config.deleteFailedJobs, + processName: process.title, + queues: [config.queue].flat(), + workoff, + clear + }); + } +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + JobManager +}); diff --git a/packages/jobs/dist/core/Scheduler.d.ts b/packages/jobs/dist/core/Scheduler.d.ts new file mode 100644 index 000000000000..a2bc990c0de8 --- /dev/null +++ b/packages/jobs/dist/core/Scheduler.d.ts @@ -0,0 +1,23 @@ +import type { BaseAdapter, SchedulePayload } from '../adapters/BaseAdapter/BaseAdapter'; +import type { BasicLogger, Job, ScheduleJobOptions } from '../types'; +interface SchedulerConfig { + adapter: TAdapter; + logger?: BasicLogger; +} +export declare class Scheduler { + adapter: TAdapter; + logger: NonNullable['logger']>; + constructor({ adapter, logger }: SchedulerConfig); + computeRunAt({ wait, waitUntil }: { + wait: number; + waitUntil: Date | null; + }): Date; + buildPayload>(job: T, args?: Parameters, options?: ScheduleJobOptions): SchedulePayload; + schedule>({ job, jobArgs, jobOptions, }: { + job: T; + jobArgs?: Parameters; + jobOptions?: ScheduleJobOptions; + }): Promise; +} +export {}; +//# sourceMappingURL=Scheduler.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/core/Scheduler.d.ts.map b/packages/jobs/dist/core/Scheduler.d.ts.map new file mode 100644 index 000000000000..b70778a2f747 --- /dev/null +++ b/packages/jobs/dist/core/Scheduler.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"Scheduler.d.ts","sourceRoot":"","sources":["../../src/core/Scheduler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,WAAW,EACX,eAAe,EAChB,MAAM,qCAAqC,CAAA;AAY5C,OAAO,KAAK,EAAE,WAAW,EAAE,GAAG,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAA;AAEpE,UAAU,eAAe,CAAC,QAAQ,SAAS,WAAW;IACpD,OAAO,EAAE,QAAQ,CAAA;IACjB,MAAM,CAAC,EAAE,WAAW,CAAA;CACrB;AAED,qBAAa,SAAS,CAAC,QAAQ,SAAS,WAAW;IACjD,OAAO,EAAE,QAAQ,CAAA;IACjB,MAAM,EAAE,WAAW,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAA;gBAE5C,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,eAAe,CAAC,QAAQ,CAAC;IAS1D,YAAY,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,IAAI,GAAG,IAAI,CAAA;KAAE;IAU1E,YAAY,CAAC,CAAC,SAAS,GAAG,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE,CAAC,EAC7C,GAAG,EAAE,CAAC,EACN,IAAI,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,EAC/B,OAAO,CAAC,EAAE,kBAAkB,GAC3B,eAAe;IAoBZ,QAAQ,CAAC,CAAC,SAAS,GAAG,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE,CAAC,EAAE,EACjD,GAAG,EACH,OAAO,EACP,UAAU,GACX,EAAE;QACD,GAAG,EAAE,CAAC,CAAA;QACN,OAAO,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAA;QAClC,UAAU,CAAC,EAAE,kBAAkB,CAAA;KAChC;CAeF"} \ No newline at end of file diff --git a/packages/jobs/dist/core/Scheduler.js b/packages/jobs/dist/core/Scheduler.js new file mode 100644 index 000000000000..d427aa872a1e --- /dev/null +++ b/packages/jobs/dist/core/Scheduler.js @@ -0,0 +1,83 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var Scheduler_exports = {}; +__export(Scheduler_exports, { + Scheduler: () => Scheduler +}); +module.exports = __toCommonJS(Scheduler_exports); +var import_consts = require("../consts"); +var import_errors = require("../errors"); +class Scheduler { + adapter; + logger; + constructor({ adapter, logger }) { + this.logger = logger ?? import_consts.DEFAULT_LOGGER; + this.adapter = adapter; + if (!this.adapter) { + throw new import_errors.AdapterNotConfiguredError(); + } + } + computeRunAt({ wait, waitUntil }) { + if (wait && wait > 0) { + return new Date(Date.now() + wait * 1e3); + } else if (waitUntil) { + return waitUntil; + } else { + return /* @__PURE__ */ new Date(); + } + } + buildPayload(job, args, options) { + const queue = job.queue; + const priority = job.priority ?? import_consts.DEFAULT_PRIORITY; + const wait = options?.wait ?? import_consts.DEFAULT_WAIT; + const waitUntil = options?.waitUntil ?? import_consts.DEFAULT_WAIT_UNTIL; + if (!queue) { + throw new import_errors.QueueNotDefinedError(); + } + return { + name: job.name, + path: job.path, + args: args ?? [], + runAt: this.computeRunAt({ wait, waitUntil }), + queue, + priority + }; + } + async schedule({ + job, + jobArgs, + jobOptions + }) { + const payload = this.buildPayload(job, jobArgs, jobOptions); + this.logger.info(payload, `[RedwoodJob] Scheduling ${job.name}`); + try { + await this.adapter.schedule(payload); + return true; + } catch (e) { + throw new import_errors.SchedulingError( + `[RedwoodJob] Exception when scheduling ${payload.name}`, + e + ); + } + } +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + Scheduler +}); diff --git a/packages/jobs/dist/core/Worker.d.ts b/packages/jobs/dist/core/Worker.d.ts new file mode 100644 index 000000000000..c0b634b1838b --- /dev/null +++ b/packages/jobs/dist/core/Worker.d.ts @@ -0,0 +1,39 @@ +import type { BaseAdapter } from '../adapters/BaseAdapter/BaseAdapter'; +import type { BasicLogger } from '../types'; +export interface WorkerOptions { + adapter: BaseAdapter; + processName: string; + queues: string[]; + logger?: BasicLogger; + clear?: boolean; + maxAttempts?: number; + maxRuntime?: number; + deleteSuccessfulJobs?: boolean; + deleteFailedJobs?: boolean; + sleepDelay?: number; + workoff?: boolean; + forever?: boolean; +} +type CompleteOptions = Required; +export declare class Worker { + #private; + options: CompleteOptions; + adapter: CompleteOptions['adapter']; + logger: CompleteOptions['logger']; + clear: CompleteOptions['clear']; + processName: CompleteOptions['processName']; + queues: CompleteOptions['queues']; + maxAttempts: CompleteOptions['maxAttempts']; + maxRuntime: CompleteOptions['maxRuntime']; + deleteSuccessfulJobs: CompleteOptions['deleteSuccessfulJobs']; + deleteFailedJobs: CompleteOptions['deleteFailedJobs']; + sleepDelay: CompleteOptions['sleepDelay']; + forever: CompleteOptions['forever']; + workoff: CompleteOptions['workoff']; + lastCheckTime: Date; + constructor(options: WorkerOptions); + run(): Promise; + get queueNames(): string; +} +export {}; +//# sourceMappingURL=Worker.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/core/Worker.d.ts.map b/packages/jobs/dist/core/Worker.d.ts.map new file mode 100644 index 000000000000..f6056b445851 --- /dev/null +++ b/packages/jobs/dist/core/Worker.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"Worker.d.ts","sourceRoot":"","sources":["../../src/core/Worker.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qCAAqC,CAAA;AAUtE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;AAI3C,MAAM,WAAW,aAAa;IAE5B,OAAO,EAAE,WAAW,CAAA;IACpB,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,MAAM,EAAE,CAAA;IAEhB,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,oBAAoB,CAAC,EAAE,OAAO,CAAA;IAC9B,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,OAAO,CAAA;IAGjB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,KAAK,eAAe,GAAG,QAAQ,CAAC,aAAa,CAAC,CAAA;AAc9C,qBAAa,MAAM;;IACjB,OAAO,EAAE,eAAe,CAAA;IACxB,OAAO,EAAE,eAAe,CAAC,SAAS,CAAC,CAAA;IACnC,MAAM,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAA;IACjC,KAAK,EAAE,eAAe,CAAC,OAAO,CAAC,CAAA;IAC/B,WAAW,EAAE,eAAe,CAAC,aAAa,CAAC,CAAA;IAC3C,MAAM,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAA;IACjC,WAAW,EAAE,eAAe,CAAC,aAAa,CAAC,CAAA;IAC3C,UAAU,EAAE,eAAe,CAAC,YAAY,CAAC,CAAA;IACzC,oBAAoB,EAAE,eAAe,CAAC,sBAAsB,CAAC,CAAA;IAC7D,gBAAgB,EAAE,eAAe,CAAC,kBAAkB,CAAC,CAAA;IACrD,UAAU,EAAE,eAAe,CAAC,YAAY,CAAC,CAAA;IACzC,OAAO,EAAE,eAAe,CAAC,SAAS,CAAC,CAAA;IACnC,OAAO,EAAE,eAAe,CAAC,SAAS,CAAC,CAAA;IACnC,aAAa,EAAE,IAAI,CAAA;gBAEP,OAAO,EAAE,aAAa;IA2DlC,GAAG;IAQH,IAAI,UAAU,WAMb;CAkDF"} \ No newline at end of file diff --git a/packages/jobs/dist/core/Worker.js b/packages/jobs/dist/core/Worker.js new file mode 100644 index 000000000000..4a2dcddccf1c --- /dev/null +++ b/packages/jobs/dist/core/Worker.js @@ -0,0 +1,134 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var Worker_exports = {}; +__export(Worker_exports, { + Worker: () => Worker +}); +module.exports = __toCommonJS(Worker_exports); +var import_node_timers = require("node:timers"); +var import_consts = require("../consts"); +var import_errors = require("../errors"); +var import_Executor = require("./Executor"); +const DEFAULT_OPTIONS = { + logger: import_consts.DEFAULT_LOGGER, + clear: false, + maxAttempts: import_consts.DEFAULT_MAX_ATTEMPTS, + maxRuntime: import_consts.DEFAULT_MAX_RUNTIME, + deleteSuccessfulJobs: import_consts.DEFAULT_DELETE_SUCCESSFUL_JOBS, + deleteFailedJobs: import_consts.DEFAULT_DELETE_FAILED_JOBS, + sleepDelay: import_consts.DEFAULT_SLEEP_DELAY, + workoff: false, + forever: true +}; +class Worker { + options; + adapter; + logger; + clear; + processName; + queues; + maxAttempts; + maxRuntime; + deleteSuccessfulJobs; + deleteFailedJobs; + sleepDelay; + forever; + workoff; + lastCheckTime; + constructor(options) { + this.options = { ...DEFAULT_OPTIONS, ...options }; + if (!options?.adapter) { + throw new import_errors.AdapterRequiredError(); + } + if (!options?.queues || options.queues.length === 0) { + throw new import_errors.QueuesRequiredError(); + } + this.adapter = this.options.adapter; + this.logger = this.options.logger; + this.clear = this.options.clear; + this.processName = this.options.processName; + this.queues = this.options.queues; + this.maxAttempts = this.options.maxAttempts; + this.maxRuntime = this.options.maxRuntime; + this.deleteSuccessfulJobs = this.options.deleteSuccessfulJobs; + this.deleteFailedJobs = this.options.deleteFailedJobs; + this.sleepDelay = this.options.sleepDelay * 1e3; + this.forever = this.options.forever; + this.workoff = this.options.workoff; + this.lastCheckTime = /* @__PURE__ */ new Date(); + } + // Workers run forever unless: + // `this.forever` to false (loop only runs once, then exits) + // `this.workoff` is true (run all jobs in the queue, then exits) + run() { + if (this.clear) { + return this.#clearQueue(); + } else { + return this.#work(); + } + } + get queueNames() { + if (this.queues.length === 1 && this.queues[0] === "*") { + return "all (*)"; + } else { + return this.queues.join(", "); + } + } + async #clearQueue() { + return await this.adapter.clear(); + } + async #work() { + do { + this.lastCheckTime = /* @__PURE__ */ new Date(); + this.logger.debug( + `[${this.processName}] Checking for jobs in ${this.queueNames} queues...` + ); + const job = await this.adapter.find({ + processName: this.processName, + maxRuntime: this.maxRuntime, + queues: this.queues + }); + if (job) { + await new import_Executor.Executor({ + adapter: this.adapter, + logger: this.logger, + job, + maxAttempts: this.maxAttempts, + deleteSuccessfulJobs: this.deleteSuccessfulJobs, + deleteFailedJobs: this.deleteFailedJobs + }).perform(); + } else if (this.workoff) { + break; + } + if (!job && this.forever) { + const millsSinceLastCheck = (/* @__PURE__ */ new Date()).getTime() - this.lastCheckTime.getTime(); + if (millsSinceLastCheck < this.sleepDelay) { + await this.#wait(this.sleepDelay - millsSinceLastCheck); + } + } + } while (this.forever); + } + #wait(ms) { + return new Promise((resolve) => (0, import_node_timers.setTimeout)(resolve, ms)); + } +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + Worker +}); diff --git a/packages/jobs/dist/errors.d.ts b/packages/jobs/dist/errors.d.ts new file mode 100644 index 000000000000..01707209f04f --- /dev/null +++ b/packages/jobs/dist/errors.d.ts @@ -0,0 +1,104 @@ +/** + * Parent class for any RedwoodJob-related error + */ +export declare class RedwoodJobError extends Error { + constructor(message: string); +} +/** + * Thrown when trying to configure a scheduler without an adapter + */ +export declare class AdapterNotConfiguredError extends RedwoodJobError { + constructor(); +} +/** + * Thrown when the Worker or Executor is instantiated without an adapter + */ +export declare class AdapterRequiredError extends RedwoodJobError { + constructor(); +} +/** + * Thrown when the Worker is instantiated without an array of queues + */ +export declare class QueuesRequiredError extends RedwoodJobError { + constructor(); +} +/** + * Thrown when the Executor is instantiated without a job + */ +export declare class JobRequiredError extends RedwoodJobError { + constructor(); +} +/** + * Thrown when a job with the given handler is not found in the filesystem + */ +export declare class JobNotFoundError extends RedwoodJobError { + constructor(name: string); +} +/** + * Thrown when a job file exists, but the export does not match the filename + */ +export declare class JobExportNotFoundError extends RedwoodJobError { + constructor(name: string); +} +/** + * Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js|ts and + * the file does not exist + */ +export declare class JobsLibNotFoundError extends RedwoodJobError { + constructor(); +} +/** + * Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js|ts + */ +export declare class AdapterNotFoundError extends RedwoodJobError { + constructor(name: string); +} +/** + * Thrown when the runner tries to import `logger` from api/src/lib/jobs.js|ts + */ +export declare class LoggerNotFoundError extends RedwoodJobError { + constructor(name: string); +} +/** + * Thrown when the runner tries to import `workerConfig` from api/src/lib/jobs.js|ts + */ +export declare class WorkerConfigNotFoundError extends RedwoodJobError { + constructor(name: string); +} +/** + * Parent class for any job error where we want to wrap the underlying error + * in our own. Use by extending this class and passing the original error to + * the constructor: + * + * ```typescript + * try { + * throw new Error('Generic error') + * } catch (e) { + * throw new RethrowJobError('Custom Error Message', e) + * } + * ``` + */ +export declare class RethrownJobError extends RedwoodJobError { + originalError: Error; + stackBeforeRethrow: string | undefined; + constructor(message: string, error: Error); +} +/** + * Thrown when there is an error scheduling a job, wraps the underlying error + */ +export declare class SchedulingError extends RethrownJobError { + constructor(message: string, error: Error); +} +/** + * Thrown when there is an error performing a job, wraps the underlying error + */ +export declare class PerformError extends RethrownJobError { + constructor(message: string, error: Error); +} +export declare class QueueNotDefinedError extends RedwoodJobError { + constructor(); +} +export declare class WorkerConfigIndexNotFoundError extends RedwoodJobError { + constructor(index: number); +} +//# sourceMappingURL=errors.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/errors.d.ts.map b/packages/jobs/dist/errors.d.ts.map new file mode 100644 index 000000000000..3f39d1fbf48c --- /dev/null +++ b/packages/jobs/dist/errors.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,qBAAa,eAAgB,SAAQ,KAAK;gBAC5B,OAAO,EAAE,MAAM;CAI5B;AAED;;GAEG;AACH,qBAAa,yBAA0B,SAAQ,eAAe;;CAI7D;AAED;;GAEG;AACH,qBAAa,oBAAqB,SAAQ,eAAe;;CAIxD;AAED;;GAEG;AACH,qBAAa,mBAAoB,SAAQ,eAAe;;CAIvD;AAED;;GAEG;AACH,qBAAa,gBAAiB,SAAQ,eAAe;;CAIpD;AAED;;GAEG;AACH,qBAAa,gBAAiB,SAAQ,eAAe;gBACvC,IAAI,EAAE,MAAM;CAGzB;AAED;;GAEG;AACH,qBAAa,sBAAuB,SAAQ,eAAe;gBAC7C,IAAI,EAAE,MAAM;CAGzB;AAED;;;GAGG;AACH,qBAAa,oBAAqB,SAAQ,eAAe;;CAMxD;AAED;;GAEG;AACH,qBAAa,oBAAqB,SAAQ,eAAe;gBAC3C,IAAI,EAAE,MAAM;CAKzB;AAED;;GAEG;AACH,qBAAa,mBAAoB,SAAQ,eAAe;gBAC1C,IAAI,EAAE,MAAM;CAKzB;AAED;;GAEG;AACH,qBAAa,yBAA0B,SAAQ,eAAe;gBAChD,IAAI,EAAE,MAAM;CAGzB;AAED;;;;;;;;;;;;GAYG;AACH,qBAAa,gBAAiB,SAAQ,eAAe;IACnD,aAAa,EAAE,KAAK,CAAA;IACpB,kBAAkB,EAAE,MAAM,GAAG,SAAS,CAAA;gBAE1B,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAqB1C;AAED;;GAEG;AACH,qBAAa,eAAgB,SAAQ,gBAAgB;gBACvC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAG1C;AAED;;GAEG;AACH,qBAAa,YAAa,SAAQ,gBAAgB;gBACpC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAG1C;AAED,qBAAa,oBAAqB,SAAQ,eAAe;;CAIxD;AAED,qBAAa,8BAA+B,SAAQ,eAAe;gBACrD,KAAK,EAAE,MAAM;CAG1B"} \ No newline at end of file diff --git a/packages/jobs/dist/errors.js b/packages/jobs/dist/errors.js new file mode 100644 index 000000000000..699f8ca67428 --- /dev/null +++ b/packages/jobs/dist/errors.js @@ -0,0 +1,156 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var errors_exports = {}; +__export(errors_exports, { + AdapterNotConfiguredError: () => AdapterNotConfiguredError, + AdapterNotFoundError: () => AdapterNotFoundError, + AdapterRequiredError: () => AdapterRequiredError, + JobExportNotFoundError: () => JobExportNotFoundError, + JobNotFoundError: () => JobNotFoundError, + JobRequiredError: () => JobRequiredError, + JobsLibNotFoundError: () => JobsLibNotFoundError, + LoggerNotFoundError: () => LoggerNotFoundError, + PerformError: () => PerformError, + QueueNotDefinedError: () => QueueNotDefinedError, + QueuesRequiredError: () => QueuesRequiredError, + RedwoodJobError: () => RedwoodJobError, + RethrownJobError: () => RethrownJobError, + SchedulingError: () => SchedulingError, + WorkerConfigIndexNotFoundError: () => WorkerConfigIndexNotFoundError, + WorkerConfigNotFoundError: () => WorkerConfigNotFoundError +}); +module.exports = __toCommonJS(errors_exports); +const JOBS_CONFIG_FILENAME = "jobs.ts/js"; +class RedwoodJobError extends Error { + constructor(message) { + super(message); + this.name = this.constructor.name; + } +} +class AdapterNotConfiguredError extends RedwoodJobError { + constructor() { + super("No adapter configured for the job scheduler"); + } +} +class AdapterRequiredError extends RedwoodJobError { + constructor() { + super("`adapter` is required to perform a job"); + } +} +class QueuesRequiredError extends RedwoodJobError { + constructor() { + super("`queues` is required to find a job to run"); + } +} +class JobRequiredError extends RedwoodJobError { + constructor() { + super("`job` is required to perform a job"); + } +} +class JobNotFoundError extends RedwoodJobError { + constructor(name) { + super(`Job \`${name}\` not found in the filesystem`); + } +} +class JobExportNotFoundError extends RedwoodJobError { + constructor(name) { + super(`Job file \`${name}\` does not export a class with the same name`); + } +} +class JobsLibNotFoundError extends RedwoodJobError { + constructor() { + super( + `api/src/lib/${JOBS_CONFIG_FILENAME} not found. Run \`yarn rw setup jobs\` to create this file and configure background jobs` + ); + } +} +class AdapterNotFoundError extends RedwoodJobError { + constructor(name) { + super( + `api/src/lib/${JOBS_CONFIG_FILENAME} does not export an adapter named \`${name}\`` + ); + } +} +class LoggerNotFoundError extends RedwoodJobError { + constructor(name) { + super( + `api/src/lib/${JOBS_CONFIG_FILENAME} does not export a logger named \`${name}\`` + ); + } +} +class WorkerConfigNotFoundError extends RedwoodJobError { + constructor(name) { + super(`api/src/lib/#{JOBS_CONFIG_FILENAME} does not export \`${name}\``); + } +} +class RethrownJobError extends RedwoodJobError { + originalError; + stackBeforeRethrow; + constructor(message, error) { + super(message); + if (!error) { + throw new Error( + "RethrownJobError requires a message and existing error object" + ); + } + this.originalError = error; + this.stackBeforeRethrow = this.stack; + const messageLines = (this.message.match(/\n/g) || []).length + 1; + this.stack = this.stack?.split("\n").slice(0, messageLines + 1).join("\n") + "\n" + error.stack; + } +} +class SchedulingError extends RethrownJobError { + constructor(message, error) { + super(message, error); + } +} +class PerformError extends RethrownJobError { + constructor(message, error) { + super(message, error); + } +} +class QueueNotDefinedError extends RedwoodJobError { + constructor() { + super("Scheduler requires a named `queue` to place jobs in"); + } +} +class WorkerConfigIndexNotFoundError extends RedwoodJobError { + constructor(index) { + super(`Worker index ${index} not found in jobs config`); + } +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + AdapterNotConfiguredError, + AdapterNotFoundError, + AdapterRequiredError, + JobExportNotFoundError, + JobNotFoundError, + JobRequiredError, + JobsLibNotFoundError, + LoggerNotFoundError, + PerformError, + QueueNotDefinedError, + QueuesRequiredError, + RedwoodJobError, + RethrownJobError, + SchedulingError, + WorkerConfigIndexNotFoundError, + WorkerConfigNotFoundError +}); diff --git a/packages/jobs/dist/index.d.ts b/packages/jobs/dist/index.d.ts new file mode 100644 index 000000000000..989b1a856416 --- /dev/null +++ b/packages/jobs/dist/index.d.ts @@ -0,0 +1,7 @@ +export * from './errors'; +export { JobManager } from './core/JobManager'; +export { Executor } from './core/Executor'; +export { Worker } from './core/Worker'; +export { BaseAdapter } from './adapters/BaseAdapter/BaseAdapter'; +export { PrismaAdapter } from './adapters/PrismaAdapter/PrismaAdapter'; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/index.d.ts.map b/packages/jobs/dist/index.d.ts.map new file mode 100644 index 000000000000..5ec20d29e766 --- /dev/null +++ b/packages/jobs/dist/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAA;AAExB,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAC9C,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAC1C,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAA;AAEtC,OAAO,EAAE,WAAW,EAAE,MAAM,oCAAoC,CAAA;AAChE,OAAO,EAAE,aAAa,EAAE,MAAM,wCAAwC,CAAA"} \ No newline at end of file diff --git a/packages/jobs/dist/index.js b/packages/jobs/dist/index.js new file mode 100644 index 000000000000..18b7a88c7045 --- /dev/null +++ b/packages/jobs/dist/index.js @@ -0,0 +1,43 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "default"), secondTarget && __copyProps(secondTarget, mod, "default")); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var src_exports = {}; +__export(src_exports, { + BaseAdapter: () => import_BaseAdapter.BaseAdapter, + Executor: () => import_Executor.Executor, + JobManager: () => import_JobManager.JobManager, + PrismaAdapter: () => import_PrismaAdapter.PrismaAdapter, + Worker: () => import_Worker.Worker +}); +module.exports = __toCommonJS(src_exports); +__reExport(src_exports, require("./errors"), module.exports); +var import_JobManager = require("./core/JobManager"); +var import_Executor = require("./core/Executor"); +var import_Worker = require("./core/Worker"); +var import_BaseAdapter = require("./adapters/BaseAdapter/BaseAdapter"); +var import_PrismaAdapter = require("./adapters/PrismaAdapter/PrismaAdapter"); +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + BaseAdapter, + Executor, + JobManager, + PrismaAdapter, + Worker, + ...require("./errors") +}); diff --git a/packages/jobs/dist/loaders.d.ts b/packages/jobs/dist/loaders.d.ts new file mode 100644 index 000000000000..661b8fef2927 --- /dev/null +++ b/packages/jobs/dist/loaders.d.ts @@ -0,0 +1,13 @@ +import type { JobManager } from './core/JobManager'; +import type { Adapters, BasicLogger, Job, JobComputedProperties } from './types'; +/** + * Loads the job manager from the users project + * + * @returns JobManager + */ +export declare const loadJobsManager: () => Promise>; +/** + * Load a specific job implementation from the users project + */ +export declare const loadJob: ({ name: jobName, path: jobPath, }: JobComputedProperties) => Promise>; +//# sourceMappingURL=loaders.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/loaders.d.ts.map b/packages/jobs/dist/loaders.d.ts.map new file mode 100644 index 000000000000..26161aba8fa7 --- /dev/null +++ b/packages/jobs/dist/loaders.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"loaders.d.ts","sourceRoot":"","sources":["../src/loaders.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAEnD,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,GAAG,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAA;AAGhF;;;;GAIG;AACH,eAAO,MAAM,eAAe,QAAa,OAAO,CAC9C,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,WAAW,CAAC,CAgB5C,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,OAAO,sCAGjB,qBAAqB,KAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE,CAAC,CAgB1D,CAAA"} \ No newline at end of file diff --git a/packages/jobs/dist/loaders.js b/packages/jobs/dist/loaders.js new file mode 100644 index 000000000000..7cb214edf7ae --- /dev/null +++ b/packages/jobs/dist/loaders.js @@ -0,0 +1,71 @@ +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var loaders_exports = {}; +__export(loaders_exports, { + loadJob: () => loadJob, + loadJobsManager: () => loadJobsManager +}); +module.exports = __toCommonJS(loaders_exports); +var import_node_fs = __toESM(require("node:fs")); +var import_node_path = __toESM(require("node:path")); +var import_project_config = require("@redwoodjs/project-config"); +var import_errors = require("./errors"); +var import_util = require("./util"); +const loadJobsManager = async () => { + const jobsConfigPath = (0, import_project_config.getPaths)().api.distJobsConfig; + if (!jobsConfigPath) { + throw new import_errors.JobsLibNotFoundError(); + } + const importPath = (0, import_util.makeFilePath)(jobsConfigPath); + const { jobs } = await import(importPath); + if (!jobs) { + throw new import_errors.JobsLibNotFoundError(); + } + return jobs; +}; +const loadJob = async ({ + name: jobName, + path: jobPath +}) => { + const completeJobPath = import_node_path.default.join((0, import_project_config.getPaths)().api.distJobs, jobPath) + ".js"; + if (!import_node_fs.default.existsSync(completeJobPath)) { + throw new import_errors.JobNotFoundError(jobName); + } + const importPath = (0, import_util.makeFilePath)(completeJobPath); + const jobModule = await import(importPath); + if (!jobModule[jobName]) { + throw new import_errors.JobNotFoundError(jobName); + } + return jobModule[jobName]; +}; +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + loadJob, + loadJobsManager +}); diff --git a/packages/jobs/dist/types.d.ts b/packages/jobs/dist/types.d.ts new file mode 100644 index 000000000000..a4f95a7e7d5a --- /dev/null +++ b/packages/jobs/dist/types.d.ts @@ -0,0 +1,152 @@ +import type { BaseAdapter } from './adapters/BaseAdapter/BaseAdapter'; +export interface BasicLogger { + debug: (message?: any, ...optionalParams: any[]) => void; + info: (message?: any, ...optionalParams: any[]) => void; + warn: (message?: any, ...optionalParams: any[]) => void; + error: (message?: any, ...optionalParams: any[]) => void; +} +export interface BaseJob { + id: string | number; + name: string; + path: string; + args: unknown[]; + attempts: number; +} +export type PossibleBaseJob = BaseJob | undefined; +export type Adapters = Record; +export interface WorkerConfig { + /** + * The name of the adapter to use for this worker. This must be one of the keys + * in the `adapters` object when you created the `JobManager`. + */ + adapter: keyof TAdapters; + /** + * The queue or queues that this worker should work on. You can pass a single + * queue name, an array of queue names, or the string `'*'` to work on all + * queues. + */ + queue: '*' | TQueues[number] | TQueues[number][]; + /** + * The maximum number of retries to attempt for a job before giving up. + * + * @default 24 + */ + maxAttempts?: number; + /** + * The maximum amount of time in seconds that a job can run before another + * worker will attempt to retry it. + * + * @default 14,400 (4 hours) + */ + maxRuntime?: number; + /** + * Whether a job that exceeds its `maxAttempts` should be deleted from the + * queue. If `false`, the job will remain in the queue but will not be + * processed further. + * + * @default false + */ + deleteFailedJobs?: boolean; + /** + * The amount of time in seconds to wait between polling the queue for new + * jobs. Some adapters may not need this if they do not poll the queue and + * instead rely on a subscription model. + * + * @default 5 + */ + sleepDelay?: number; + /** + * The number of workers to spawn for this worker configuration. + * + * @default 1 + */ + count?: number; + /** + * The logger to use for this worker. If not provided, the logger from the + * `JobManager` will be used. + */ + logger?: BasicLogger; +} +export interface JobManagerConfig { + /** + * An object containing all of the adapters that this job manager will use. + * The keys should be the names of the adapters and the values should be the + * adapter instances. + */ + adapters: TAdapters; + /** + * The logger to use for this job manager. If not provided, the logger will + * default to the console. + */ + logger: TLogger; + /** + * An array of all of queue names that jobs can be scheduled on to. Workers can + * be configured to work on a selection of these queues. + */ + queues: TQueues; + /** + * An array of worker configurations that define how jobs should be processed. + */ + workers: WorkerConfig[]; +} +export interface CreateSchedulerConfig { + /** + * The name of the adapter to use for this scheduler. This must be one of the keys + * in the `adapters` object when you created the `JobManager`. + */ + adapter: keyof TAdapters; + /** + * The logger to use for this scheduler. If not provided, the logger from the + * `JobManager` will be used. + */ + logger?: BasicLogger; +} +export interface JobDefinition { + /** + * The name of the queue that this job should always be scheduled on. This defaults + * to the queue that the scheduler was created with, but can be overridden when + * scheduling a job. + */ + queue: TQueues[number]; + /** + * The priority of the job in the range of 0-100. The lower the number, the + * higher the priority. The default is 50. + * @default 50 + */ + priority?: PriorityValue; + /** + * The function to run when this job is executed. + * + * @param args The arguments that were passed when the job was scheduled. + */ + perform: (...args: TArgs) => Promise | void; +} +export type JobComputedProperties = { + /** + * The name of the job that was defined in the job file. + */ + name: string; + /** + * The path to the job file that this job was defined in. + */ + path: string; +}; +export type Job = JobDefinition & JobComputedProperties; +export type ScheduleJobOptions = { + /** + * The number of seconds to wait before scheduling this job. This is mutually + * exclusive with `waitUntil`. + */ + wait: number; + waitUntil?: never; +} | { + wait?: never; + /** + * The date and time to schedule this job for. This is mutually exclusive with + * `wait`. + */ + waitUntil: Date; +}; +type PriorityValue = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100; +export {}; +//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/types.d.ts.map b/packages/jobs/dist/types.d.ts.map new file mode 100644 index 000000000000..190db9703a85 --- /dev/null +++ b/packages/jobs/dist/types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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,CAAC,GACD,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,GAAG,CAAA"} \ No newline at end of file diff --git a/packages/jobs/dist/types.js b/packages/jobs/dist/types.js new file mode 100644 index 000000000000..43ae53610554 --- /dev/null +++ b/packages/jobs/dist/types.js @@ -0,0 +1,16 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var types_exports = {}; +module.exports = __toCommonJS(types_exports); diff --git a/packages/jobs/dist/util.d.ts b/packages/jobs/dist/util.d.ts new file mode 100644 index 000000000000..b10a4aeb18a6 --- /dev/null +++ b/packages/jobs/dist/util.d.ts @@ -0,0 +1,2 @@ +export declare function makeFilePath(path: string): string; +//# sourceMappingURL=util.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/util.d.ts.map b/packages/jobs/dist/util.d.ts.map new file mode 100644 index 000000000000..13d0f150c6c6 --- /dev/null +++ b/packages/jobs/dist/util.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAGA,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,UAExC"} \ No newline at end of file diff --git a/packages/jobs/dist/util.js b/packages/jobs/dist/util.js new file mode 100644 index 000000000000..8ea5e44be1cd --- /dev/null +++ b/packages/jobs/dist/util.js @@ -0,0 +1,31 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var util_exports = {}; +__export(util_exports, { + makeFilePath: () => makeFilePath +}); +module.exports = __toCommonJS(util_exports); +var import_node_url = require("node:url"); +function makeFilePath(path) { + return (0, import_node_url.pathToFileURL)(path).href; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + makeFilePath +}); From 1b3b4ac532aaa9bab6cacbee75218167bd61f7e1 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Mon, 19 Aug 2024 18:47:09 +0100 Subject: [PATCH 226/258] update build --- packages/jobs/build.mts | 28 ++++++++++++++++++++-------- packages/jobs/package.json | 26 +++++++++++++++++++++++--- packages/jobs/tsconfig.cjs.json | 7 +++++++ 3 files changed, 50 insertions(+), 11 deletions(-) create mode 100644 packages/jobs/tsconfig.cjs.json diff --git a/packages/jobs/build.mts b/packages/jobs/build.mts index df7b613cf130..1b29b6313cd0 100644 --- a/packages/jobs/build.mts +++ b/packages/jobs/build.mts @@ -1,16 +1,28 @@ import { build, defaultBuildOptions } from '@redwoodjs/framework-tools' +import { + generateTypesCjs, + generateTypesEsm, + insertCommonJsPackageJson, +} from '@redwoodjs/framework-tools/generateTypes' -// CJS build +// ESM build and type generation await build({ buildOptions: { ...defaultBuildOptions, + format: 'esm', }, }) +await generateTypesEsm() -// ESM build -// await build({ -// buildOptions: { -// ...defaultBuildOptions, -// format: 'esm', -// }, -// }) +// CJS build, type generation, and package.json insert +await build({ + buildOptions: { + ...defaultBuildOptions, + outdir: 'dist/cjs', + }, +}) +await generateTypesCjs() +await insertCommonJsPackageJson({ + buildFileUrl: import.meta.url, + cjsDir: 'dist/cjs', +}) diff --git a/packages/jobs/package.json b/packages/jobs/package.json index a9ce53717b71..a2ffaa127a7e 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -7,7 +7,21 @@ "directory": "packages/jobs" }, "license": "MIT", - "main": "./dist/index.js", + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "default": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } + } + }, + "main": "./dist/cjs/index.js", + "module": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { "rw-jobs": "./dist/bins/rw-jobs.js", @@ -17,17 +31,23 @@ "dist" ], "scripts": { - "build": "tsx ./build.mts && yarn build:types", + "build": "tsx ./build.mts", "build:pack": "yarn pack -o redwoodjs-jobs.tgz", - "build:types": "tsc --build --verbose", + "build:types": "tsc --build --verbose ./tsconfig.json", + "build:types-cjs": "tsc --build --verbose ./tsconfig.cjs.json", "build:watch": "nodemon --watch src --ext \"js,jsx,ts,tsx\" --ignore dist --exec \"yarn build\"", + "check:attw": "yarn rw-fwtools-attw", + "check:package": "concurrently npm:check:attw yarn:publint", "prepublishOnly": "NODE_ENV=production yarn build", "test": "vitest run", "test:watch": "vitest" }, "devDependencies": { "@prisma/client": "5.18.0", + "@redwoodjs/framework-tools": "workspace:*", "@redwoodjs/project-config": "workspace:*", + "concurrently": "8.2.2", + "publint": "0.2.10", "tsx": "4.17.0", "typescript": "5.5.4", "vitest": "2.0.5" diff --git a/packages/jobs/tsconfig.cjs.json b/packages/jobs/tsconfig.cjs.json new file mode 100644 index 000000000000..eaa211040f2f --- /dev/null +++ b/packages/jobs/tsconfig.cjs.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/cjs", + "tsBuildInfoFile": "./tsconfig.cjs.tsbuildinfo" + } +} From eef23633897cbbb3b9f61b8f0b8c9e213e58caf7 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Mon, 19 Aug 2024 18:51:17 +0100 Subject: [PATCH 227/258] add relative extensions --- .../jobs/src/adapters/BaseAdapter/BaseAdapter.ts | 4 ++-- .../src/adapters/PrismaAdapter/PrismaAdapter.ts | 10 +++++----- packages/jobs/src/adapters/PrismaAdapter/errors.ts | 2 +- packages/jobs/src/bins/rw-jobs-worker.ts | 10 +++++----- packages/jobs/src/bins/rw-jobs.ts | 6 +++--- packages/jobs/src/core/Executor.ts | 10 +++++----- packages/jobs/src/core/JobManager.ts | 10 +++++----- packages/jobs/src/core/Scheduler.ts | 8 ++++---- packages/jobs/src/core/Worker.ts | 10 +++++----- packages/jobs/src/index.ts | 12 ++++++------ packages/jobs/src/loaders.ts | 13 +++++++++---- packages/jobs/src/types.ts | 2 +- 12 files changed, 51 insertions(+), 46 deletions(-) diff --git a/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts b/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts index 6ad5aa6af66a..90e907d922f9 100644 --- a/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts +++ b/packages/jobs/src/adapters/BaseAdapter/BaseAdapter.ts @@ -1,5 +1,5 @@ -import { DEFAULT_LOGGER } from '../../consts' -import type { BaseJob, BasicLogger, PossibleBaseJob } from '../../types' +import { DEFAULT_LOGGER } from '../../consts.js' +import type { BaseJob, BasicLogger, PossibleBaseJob } from '../../types.js' // Arguments sent to an adapter to schedule a job export interface SchedulePayload { diff --git a/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts b/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts index e3826e53a3ce..05e036013173 100644 --- a/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts +++ b/packages/jobs/src/adapters/PrismaAdapter/PrismaAdapter.ts @@ -1,8 +1,8 @@ import type { PrismaClient } from '@prisma/client' import { camelCase } from 'change-case' -import { DEFAULT_MAX_RUNTIME, DEFAULT_MODEL_NAME } from '../../consts' -import type { BaseJob } from '../../types' +import { DEFAULT_MAX_RUNTIME, DEFAULT_MODEL_NAME } from '../../consts.js' +import type { BaseJob } from '../../types.js' import type { BaseAdapterOptions, SchedulePayload, @@ -10,10 +10,10 @@ import type { SuccessOptions, ErrorOptions, FailureOptions, -} from '../BaseAdapter/BaseAdapter' -import { BaseAdapter } from '../BaseAdapter/BaseAdapter' +} from '../BaseAdapter/BaseAdapter.js' +import { BaseAdapter } from '../BaseAdapter/BaseAdapter.js' -import { ModelNameError } from './errors' +import { ModelNameError } from './errors.js' export interface PrismaJob extends BaseJob { id: number diff --git a/packages/jobs/src/adapters/PrismaAdapter/errors.ts b/packages/jobs/src/adapters/PrismaAdapter/errors.ts index e6a237b09b8a..9a9fc95f1640 100644 --- a/packages/jobs/src/adapters/PrismaAdapter/errors.ts +++ b/packages/jobs/src/adapters/PrismaAdapter/errors.ts @@ -1,4 +1,4 @@ -import { RedwoodJobError } from '../../errors' +import { RedwoodJobError } from '../../errors.js' // Thrown when a given model name isn't actually available in the PrismaClient export class ModelNameError extends RedwoodJobError { diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index f5cde1ede9f6..1f1fca717036 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -11,11 +11,11 @@ import yargs from 'yargs/yargs' import { loadEnvFiles } from '@redwoodjs/cli-helpers/loadEnvFiles' -import { DEFAULT_LOGGER, PROCESS_TITLE_PREFIX } from '../consts' -import type { Worker } from '../core/Worker' -import { WorkerConfigIndexNotFoundError } from '../errors' -import { loadJobsManager } from '../loaders' -import type { BasicLogger } from '../types' +import { DEFAULT_LOGGER, PROCESS_TITLE_PREFIX } from '../consts.js' +import type { Worker } from '../core/Worker.js' +import { WorkerConfigIndexNotFoundError } from '../errors.js' +import { loadJobsManager } from '../loaders.js' +import type { BasicLogger } from '../types.js' loadEnvFiles() diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index 0fc97ea04aa6..55cc6bb8b4b9 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -15,9 +15,9 @@ import yargs from 'yargs/yargs' import { loadEnvFiles } from '@redwoodjs/cli-helpers/loadEnvFiles' -import { DEFAULT_LOGGER, PROCESS_TITLE_PREFIX } from '../consts' -import { loadJobsManager } from '../loaders' -import type { Adapters, BasicLogger, WorkerConfig } from '../types' +import { DEFAULT_LOGGER, PROCESS_TITLE_PREFIX } from '../consts.js' +import { loadJobsManager } from '../loaders.js' +import type { Adapters, BasicLogger, WorkerConfig } from '../types.js' export type NumWorkersConfig = [number, number][] diff --git a/packages/jobs/src/core/Executor.ts b/packages/jobs/src/core/Executor.ts index aed833258a49..51ae7bc040c2 100644 --- a/packages/jobs/src/core/Executor.ts +++ b/packages/jobs/src/core/Executor.ts @@ -1,15 +1,15 @@ // Used by the job runner to execute a job and track success or failure -import type { BaseAdapter } from '../adapters/BaseAdapter/BaseAdapter' +import type { BaseAdapter } from '../adapters/BaseAdapter/BaseAdapter.js' import { DEFAULT_MAX_ATTEMPTS, DEFAULT_DELETE_FAILED_JOBS, DEFAULT_DELETE_SUCCESSFUL_JOBS, DEFAULT_LOGGER, -} from '../consts' -import { AdapterRequiredError, JobRequiredError } from '../errors' -import { loadJob } from '../loaders' -import type { BaseJob, BasicLogger } from '../types' +} from '../consts.js' +import { AdapterRequiredError, JobRequiredError } from '../errors.js' +import { loadJob } from '../loaders.js' +import type { BaseJob, BasicLogger } from '../types.js' interface Options { adapter: BaseAdapter diff --git a/packages/jobs/src/core/JobManager.ts b/packages/jobs/src/core/JobManager.ts index 3277d90bf000..70dd10152492 100644 --- a/packages/jobs/src/core/JobManager.ts +++ b/packages/jobs/src/core/JobManager.ts @@ -1,4 +1,4 @@ -import { AdapterNotFoundError } from '../errors' +import { AdapterNotFoundError } from '../errors.js' import type { Adapters, BasicLogger, @@ -8,11 +8,11 @@ import type { JobManagerConfig, ScheduleJobOptions, WorkerConfig, -} from '../types' +} from '../types.js' -import { Scheduler } from './Scheduler' -import type { WorkerOptions } from './Worker' -import { Worker } from './Worker' +import { Scheduler } from './Scheduler.js' +import type { WorkerOptions } from './Worker.js' +import { Worker } from './Worker.js' export interface CreateWorkerArgs { index: number diff --git a/packages/jobs/src/core/Scheduler.ts b/packages/jobs/src/core/Scheduler.ts index a4d80f92b93a..d6eb0e971bd6 100644 --- a/packages/jobs/src/core/Scheduler.ts +++ b/packages/jobs/src/core/Scheduler.ts @@ -1,19 +1,19 @@ import type { BaseAdapter, SchedulePayload, -} from '../adapters/BaseAdapter/BaseAdapter' +} from '../adapters/BaseAdapter/BaseAdapter.js' import { DEFAULT_LOGGER, DEFAULT_PRIORITY, DEFAULT_WAIT, DEFAULT_WAIT_UNTIL, -} from '../consts' +} from '../consts.js' import { AdapterNotConfiguredError, QueueNotDefinedError, SchedulingError, -} from '../errors' -import type { BasicLogger, Job, ScheduleJobOptions } from '../types' +} from '../errors.js' +import type { BasicLogger, Job, ScheduleJobOptions } from '../types.js' interface SchedulerConfig { adapter: TAdapter diff --git a/packages/jobs/src/core/Worker.ts b/packages/jobs/src/core/Worker.ts index c27f7da3651a..1958caabf8d3 100644 --- a/packages/jobs/src/core/Worker.ts +++ b/packages/jobs/src/core/Worker.ts @@ -2,7 +2,7 @@ import { setTimeout } from 'node:timers' -import type { BaseAdapter } from '../adapters/BaseAdapter/BaseAdapter' +import type { BaseAdapter } from '../adapters/BaseAdapter/BaseAdapter.js' import { DEFAULT_DELETE_FAILED_JOBS, DEFAULT_DELETE_SUCCESSFUL_JOBS, @@ -10,11 +10,11 @@ import { DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_RUNTIME, DEFAULT_SLEEP_DELAY, -} from '../consts' -import { AdapterRequiredError, QueuesRequiredError } from '../errors' -import type { BasicLogger } from '../types' +} from '../consts.js' +import { AdapterRequiredError, QueuesRequiredError } from '../errors.js' +import type { BasicLogger } from '../types.js' -import { Executor } from './Executor' +import { Executor } from './Executor.js' export interface WorkerOptions { // required diff --git a/packages/jobs/src/index.ts b/packages/jobs/src/index.ts index c73494657a47..850957f3dcee 100644 --- a/packages/jobs/src/index.ts +++ b/packages/jobs/src/index.ts @@ -1,8 +1,8 @@ -export * from './errors' +export * from './errors.js' -export { JobManager } from './core/JobManager' -export { Executor } from './core/Executor' -export { Worker } from './core/Worker' +export { JobManager } from './core/JobManager.js' +export { Executor } from './core/Executor.js' +export { Worker } from './core/Worker.js' -export { BaseAdapter } from './adapters/BaseAdapter/BaseAdapter' -export { PrismaAdapter } from './adapters/PrismaAdapter/PrismaAdapter' +export { BaseAdapter } from './adapters/BaseAdapter/BaseAdapter.js' +export { PrismaAdapter } from './adapters/PrismaAdapter/PrismaAdapter.js' diff --git a/packages/jobs/src/loaders.ts b/packages/jobs/src/loaders.ts index e7968d3e07ac..303fb4e73f2e 100644 --- a/packages/jobs/src/loaders.ts +++ b/packages/jobs/src/loaders.ts @@ -3,10 +3,15 @@ import path from 'node:path' import { getPaths } from '@redwoodjs/project-config' -import type { JobManager } from './core/JobManager' -import { JobsLibNotFoundError, JobNotFoundError } from './errors' -import type { Adapters, BasicLogger, Job, JobComputedProperties } from './types' -import { makeFilePath } from './util' +import type { JobManager } from './core/JobManager.js' +import { JobsLibNotFoundError, JobNotFoundError } from './errors.js' +import type { + Adapters, + BasicLogger, + Job, + JobComputedProperties, +} from './types.js' +import { makeFilePath } from './util.js' /** * Loads the job manager from the users project diff --git a/packages/jobs/src/types.ts b/packages/jobs/src/types.ts index e07d57278a41..a6c53fe19467 100644 --- a/packages/jobs/src/types.ts +++ b/packages/jobs/src/types.ts @@ -2,7 +2,7 @@ // debug messages. RedwoodJob will fallback to use `console` if no // logger is passed in to RedwoodJob or any adapter. Luckily both Redwood's -import type { BaseAdapter } from './adapters/BaseAdapter/BaseAdapter' +import type { BaseAdapter } from './adapters/BaseAdapter/BaseAdapter.js' // Redwood's logger and the standard console logger conform to this shape. export interface BasicLogger { From d7e8e5aae4cf9216d8b2d5ad640c3aa0d0def2d0 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Mon, 19 Aug 2024 18:56:07 +0100 Subject: [PATCH 228/258] untrack dist --- .gitignore | 2 - .../adapters/BaseAdapter/BaseAdapter.d.ts | 64 ----- .../adapters/BaseAdapter/BaseAdapter.d.ts.map | 1 - .../dist/adapters/BaseAdapter/BaseAdapter.js | 36 --- .../adapters/PrismaAdapter/PrismaAdapter.d.ts | 71 ------ .../PrismaAdapter/PrismaAdapter.d.ts.map | 1 - .../adapters/PrismaAdapter/PrismaAdapter.js | 193 --------------- .../dist/adapters/PrismaAdapter/errors.d.ts | 5 - .../adapters/PrismaAdapter/errors.d.ts.map | 1 - .../dist/adapters/PrismaAdapter/errors.js | 33 --- packages/jobs/dist/bins/rw-jobs-worker.d.ts | 3 - .../jobs/dist/bins/rw-jobs-worker.d.ts.map | 1 - packages/jobs/dist/bins/rw-jobs-worker.js | 105 --------- packages/jobs/dist/bins/rw-jobs.d.ts | 3 - packages/jobs/dist/bins/rw-jobs.d.ts.map | 1 - packages/jobs/dist/bins/rw-jobs.js | 223 ------------------ packages/jobs/dist/consts.d.ts | 26 -- packages/jobs/dist/consts.d.ts.map | 1 - packages/jobs/dist/consts.js | 81 ------- packages/jobs/dist/core/Executor.d.ts | 30 --- packages/jobs/dist/core/Executor.d.ts.map | 1 - packages/jobs/dist/core/Executor.js | 95 -------- packages/jobs/dist/core/JobManager.d.ts | 19 -- packages/jobs/dist/core/JobManager.d.ts.map | 1 - packages/jobs/dist/core/JobManager.js | 73 ------ packages/jobs/dist/core/Scheduler.d.ts | 23 -- packages/jobs/dist/core/Scheduler.d.ts.map | 1 - packages/jobs/dist/core/Scheduler.js | 83 ------- packages/jobs/dist/core/Worker.d.ts | 39 --- packages/jobs/dist/core/Worker.d.ts.map | 1 - packages/jobs/dist/core/Worker.js | 134 ----------- packages/jobs/dist/errors.d.ts | 104 -------- packages/jobs/dist/errors.d.ts.map | 1 - packages/jobs/dist/errors.js | 156 ------------ packages/jobs/dist/index.d.ts | 7 - packages/jobs/dist/index.d.ts.map | 1 - packages/jobs/dist/index.js | 43 ---- packages/jobs/dist/loaders.d.ts | 13 - packages/jobs/dist/loaders.d.ts.map | 1 - packages/jobs/dist/loaders.js | 71 ------ packages/jobs/dist/types.d.ts | 152 ------------ packages/jobs/dist/types.d.ts.map | 1 - packages/jobs/dist/types.js | 16 -- packages/jobs/dist/util.d.ts | 2 - packages/jobs/dist/util.d.ts.map | 1 - packages/jobs/dist/util.js | 31 --- yarn.lock | 3 + 47 files changed, 3 insertions(+), 1951 deletions(-) delete mode 100644 packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.d.ts delete mode 100644 packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.d.ts.map delete mode 100644 packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.js delete mode 100644 packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts delete mode 100644 packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts.map delete mode 100644 packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.js delete mode 100644 packages/jobs/dist/adapters/PrismaAdapter/errors.d.ts delete mode 100644 packages/jobs/dist/adapters/PrismaAdapter/errors.d.ts.map delete mode 100644 packages/jobs/dist/adapters/PrismaAdapter/errors.js delete mode 100644 packages/jobs/dist/bins/rw-jobs-worker.d.ts delete mode 100644 packages/jobs/dist/bins/rw-jobs-worker.d.ts.map delete mode 100755 packages/jobs/dist/bins/rw-jobs-worker.js delete mode 100644 packages/jobs/dist/bins/rw-jobs.d.ts delete mode 100644 packages/jobs/dist/bins/rw-jobs.d.ts.map delete mode 100755 packages/jobs/dist/bins/rw-jobs.js delete mode 100644 packages/jobs/dist/consts.d.ts delete mode 100644 packages/jobs/dist/consts.d.ts.map delete mode 100644 packages/jobs/dist/consts.js delete mode 100644 packages/jobs/dist/core/Executor.d.ts delete mode 100644 packages/jobs/dist/core/Executor.d.ts.map delete mode 100644 packages/jobs/dist/core/Executor.js delete mode 100644 packages/jobs/dist/core/JobManager.d.ts delete mode 100644 packages/jobs/dist/core/JobManager.d.ts.map delete mode 100644 packages/jobs/dist/core/JobManager.js delete mode 100644 packages/jobs/dist/core/Scheduler.d.ts delete mode 100644 packages/jobs/dist/core/Scheduler.d.ts.map delete mode 100644 packages/jobs/dist/core/Scheduler.js delete mode 100644 packages/jobs/dist/core/Worker.d.ts delete mode 100644 packages/jobs/dist/core/Worker.d.ts.map delete mode 100644 packages/jobs/dist/core/Worker.js delete mode 100644 packages/jobs/dist/errors.d.ts delete mode 100644 packages/jobs/dist/errors.d.ts.map delete mode 100644 packages/jobs/dist/errors.js delete mode 100644 packages/jobs/dist/index.d.ts delete mode 100644 packages/jobs/dist/index.d.ts.map delete mode 100644 packages/jobs/dist/index.js delete mode 100644 packages/jobs/dist/loaders.d.ts delete mode 100644 packages/jobs/dist/loaders.d.ts.map delete mode 100644 packages/jobs/dist/loaders.js delete mode 100644 packages/jobs/dist/types.d.ts delete mode 100644 packages/jobs/dist/types.d.ts.map delete mode 100644 packages/jobs/dist/types.js delete mode 100644 packages/jobs/dist/util.d.ts delete mode 100644 packages/jobs/dist/util.d.ts.map delete mode 100644 packages/jobs/dist/util.js diff --git a/.gitignore b/.gitignore index 7acacd503f74..fc106d99ca1f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,5 +31,3 @@ packages/create-redwood-app/create-redwood-app.tgz .nx/cache .nx/workspace-data - -!packages/jobs/dist diff --git a/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.d.ts b/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.d.ts deleted file mode 100644 index cd0979c822d0..000000000000 --- a/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.d.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { BaseJob, BasicLogger, PossibleBaseJob } from '../../types'; -export interface SchedulePayload { - name: string; - path: string; - args: unknown[]; - runAt: Date; - queue: string; - priority: number; -} -export interface FindArgs { - processName: string; - maxRuntime: number; - queues: string[]; -} -export interface BaseAdapterOptions { - logger?: BasicLogger; -} -export interface SuccessOptions { - job: TJob; - deleteJob?: boolean; -} -export interface ErrorOptions { - job: TJob; - error: Error; -} -export interface FailureOptions { - job: TJob; - deleteJob?: boolean; -} -/** - * Base class for all job adapters. Provides a common interface for scheduling - * jobs. At a minimum, you must implement the `schedule` method in your adapter. - * - * Any object passed to the constructor is saved in `this.options` and should - * be used to configure your custom adapter. If `options.logger` is included - * you can access it via `this.logger` - */ -export declare abstract class BaseAdapter> { - options: TOptions; - logger: NonNullable; - constructor(options: TOptions); - abstract schedule(payload: SchedulePayload): TScheduleReturn; - /** - * Find a single job that's eligible to run with the given args - */ - abstract find(args: FindArgs): PossibleBaseJob | Promise; - /** - * Called when a job has successfully completed - */ - abstract success(options: SuccessOptions): void | Promise; - /** - * Called when an attempt to run a job produced an error - */ - abstract error(options: ErrorOptions): void | Promise; - /** - * Called when a job has errored more than maxAttempts and will not be retried - */ - abstract failure(options: FailureOptions): void | Promise; - /** - * Clear all jobs from storage - */ - abstract clear(): void | Promise; -} -//# sourceMappingURL=BaseAdapter.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.d.ts.map b/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.d.ts.map deleted file mode 100644 index d41f20c079f6..000000000000 --- a/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"BaseAdapter.d.ts","sourceRoot":"","sources":["../../../src/adapters/BaseAdapter/BaseAdapter.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAGxE,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,OAAO,EAAE,CAAA;IACf,KAAK,EAAE,IAAI,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,QAAQ;IACvB,WAAW,EAAE,MAAM,CAAA;IACnB,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,EAAE,CAAA;CACjB;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,WAAW,CAAA;CACrB;AAED,MAAM,WAAW,cAAc,CAAC,IAAI,SAAS,OAAO,GAAG,OAAO;IAC5D,GAAG,EAAE,IAAI,CAAA;IACT,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAED,MAAM,WAAW,YAAY,CAAC,IAAI,SAAS,OAAO,GAAG,OAAO;IAC1D,GAAG,EAAE,IAAI,CAAA;IACT,KAAK,EAAE,KAAK,CAAA;CACb;AAED,MAAM,WAAW,cAAc,CAAC,IAAI,SAAS,OAAO,GAAG,OAAO;IAC5D,GAAG,EAAE,IAAI,CAAA;IACT,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAED;;;;;;;GAOG;AACH,8BAAsB,WAAW,CAC/B,QAAQ,SAAS,kBAAkB,GAAG,kBAAkB,EACxD,eAAe,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAEtC,OAAO,EAAE,QAAQ,CAAA;IACjB,MAAM,EAAE,WAAW,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAA;gBAE3B,OAAO,EAAE,QAAQ;IAU7B,QAAQ,CAAC,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,eAAe;IAE5D;;OAEG;IACH,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,GAAG,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC;IAEzE;;OAEG;IACH,QAAQ,CAAC,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAE/D;;OAEG;IACH,QAAQ,CAAC,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAE3D;;OAEG;IACH,QAAQ,CAAC,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAE/D;;OAEG;IACH,QAAQ,CAAC,KAAK,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;CACvC"} \ No newline at end of file diff --git a/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.js b/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.js deleted file mode 100644 index 82b8d6db0efc..000000000000 --- a/packages/jobs/dist/adapters/BaseAdapter/BaseAdapter.js +++ /dev/null @@ -1,36 +0,0 @@ -"use strict"; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); -var BaseAdapter_exports = {}; -__export(BaseAdapter_exports, { - BaseAdapter: () => BaseAdapter -}); -module.exports = __toCommonJS(BaseAdapter_exports); -var import_consts = require("../../consts"); -class BaseAdapter { - options; - logger; - constructor(options) { - this.options = options; - this.logger = options?.logger ?? import_consts.DEFAULT_LOGGER; - } -} -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - BaseAdapter -}); diff --git a/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts b/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts deleted file mode 100644 index 5e6c2d5f38ff..000000000000 --- a/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { PrismaClient } from '@prisma/client'; -import type { BaseJob } from '../../types'; -import type { BaseAdapterOptions, SchedulePayload, FindArgs, SuccessOptions, ErrorOptions, FailureOptions } from '../BaseAdapter/BaseAdapter'; -import { BaseAdapter } from '../BaseAdapter/BaseAdapter'; -export interface PrismaJob extends BaseJob { - id: number; - handler: string; - runAt: Date; - lockedAt: Date; - lockedBy: string; - lastError: string | null; - failedAt: Date | null; - createdAt: Date; - updatedAt: Date; -} -export interface PrismaAdapterOptions extends BaseAdapterOptions { - /** - * An instance of PrismaClient which will be used to talk to the database - */ - db: PrismaClient; - /** - * The name of the model in the Prisma schema that represents the job table. - * @default 'BackgroundJob' - */ - model?: string; -} -/** - * Implements a job adapter using Prisma ORM. - * - * Assumes a table exists with the following schema (the table name can be customized): - * ```prisma - * model BackgroundJob { - * id Int \@id \@default(autoincrement()) - * attempts Int \@default(0) - * handler String - * queue String - * priority Int - * runAt DateTime - * lockedAt DateTime? - * lockedBy String? - * lastError String? - * failedAt DateTime? - * createdAt DateTime \@default(now()) - * updatedAt DateTime \@updatedAt - * } - * ``` - */ -export declare class PrismaAdapter extends BaseAdapter { - db: PrismaClient; - model: string; - accessor: PrismaClient[keyof PrismaClient]; - provider: string; - constructor(options: PrismaAdapterOptions); - /** - * Finds the next job to run, locking it so that no other process can pick it - * The act of locking a job is dependant on the DB server, so we'll run some - * raw SQL to do it in each case—Prisma doesn't provide enough flexibility - * in their generated code to do this in a DB-agnostic way. - * - * TODO: there may be more optimized versions of the locking queries in - * Postgres and MySQL - */ - find({ processName, maxRuntime, queues, }: FindArgs): Promise; - success({ job, deleteJob }: SuccessOptions): Promise; - error({ job, error }: ErrorOptions): Promise; - failure({ job, deleteJob }: FailureOptions): Promise; - schedule({ name, path, args, runAt, queue, priority, }: SchedulePayload): Promise; - clear(): Promise; - backoffMilliseconds(attempts: number): number; -} -//# sourceMappingURL=PrismaAdapter.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts.map b/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts.map deleted file mode 100644 index 86b12f34754f..000000000000 --- a/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"PrismaAdapter.d.ts","sourceRoot":"","sources":["../../../src/adapters/PrismaAdapter/PrismaAdapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAIlD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAA;AAC1C,OAAO,KAAK,EACV,kBAAkB,EAClB,eAAe,EACf,QAAQ,EACR,cAAc,EACd,YAAY,EACZ,cAAc,EACf,MAAM,4BAA4B,CAAA;AACnC,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAA;AAIxD,MAAM,WAAW,SAAU,SAAQ,OAAO;IACxC,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,IAAI,CAAA;IACX,QAAQ,EAAE,IAAI,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,QAAQ,EAAE,IAAI,GAAG,IAAI,CAAA;IACrB,SAAS,EAAE,IAAI,CAAA;IACf,SAAS,EAAE,IAAI,CAAA;CAChB;AAED,MAAM,WAAW,oBAAqB,SAAQ,kBAAkB;IAC9D;;OAEG;IACH,EAAE,EAAE,YAAY,CAAA;IAEhB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAUD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,aAAc,SAAQ,WAAW,CAAC,oBAAoB,CAAC;IAClE,EAAE,EAAE,YAAY,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,YAAY,CAAC,MAAM,YAAY,CAAC,CAAA;IAC1C,QAAQ,EAAE,MAAM,CAAA;gBAEJ,OAAO,EAAE,oBAAoB;IAqBzC;;;;;;;;OAQG;IACY,IAAI,CAAC,EAClB,WAAW,EACX,UAAU,EACV,MAAM,GACP,EAAE,QAAQ,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IA0F7B,OAAO,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,EAAE,cAAc,CAAC,SAAS,CAAC;IAkBrD,KAAK,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE,YAAY,CAAC,SAAS,CAAC;IAqB7C,OAAO,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,EAAE,cAAc,CAAC,SAAS,CAAC;IAYrD,QAAQ,CAAC,EACtB,IAAI,EACJ,IAAI,EACJ,IAAI,EACJ,KAAK,EACL,KAAK,EACL,QAAQ,GACT,EAAE,eAAe;IAWH,KAAK;IAIpB,mBAAmB,CAAC,QAAQ,EAAE,MAAM;CAGrC"} \ No newline at end of file diff --git a/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.js b/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.js deleted file mode 100644 index ea77b8917b6b..000000000000 --- a/packages/jobs/dist/adapters/PrismaAdapter/PrismaAdapter.js +++ /dev/null @@ -1,193 +0,0 @@ -"use strict"; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); -var PrismaAdapter_exports = {}; -__export(PrismaAdapter_exports, { - PrismaAdapter: () => PrismaAdapter -}); -module.exports = __toCommonJS(PrismaAdapter_exports); -var import_change_case = require("change-case"); -var import_consts = require("../../consts"); -var import_BaseAdapter = require("../BaseAdapter/BaseAdapter"); -var import_errors = require("./errors"); -class PrismaAdapter extends import_BaseAdapter.BaseAdapter { - db; - model; - accessor; - provider; - constructor(options) { - super(options); - this.db = options.db; - this.model = options.model || import_consts.DEFAULT_MODEL_NAME; - this.accessor = this.db[(0, import_change_case.camelCase)(this.model)]; - this.provider = options.db._activeProvider; - if (!this.accessor) { - throw new import_errors.ModelNameError(this.model); - } - } - /** - * Finds the next job to run, locking it so that no other process can pick it - * The act of locking a job is dependant on the DB server, so we'll run some - * raw SQL to do it in each case—Prisma doesn't provide enough flexibility - * in their generated code to do this in a DB-agnostic way. - * - * TODO: there may be more optimized versions of the locking queries in - * Postgres and MySQL - */ - async find({ - processName, - maxRuntime, - queues - }) { - const maxRuntimeExpire = new Date( - (/* @__PURE__ */ new Date()).getTime() + (maxRuntime || import_consts.DEFAULT_MAX_RUNTIME * 1e3) - ); - const where = { - AND: [ - { - OR: [ - { - AND: [ - { runAt: { lte: /* @__PURE__ */ new Date() } }, - { - OR: [ - { lockedAt: null }, - { - lockedAt: { - lt: maxRuntimeExpire - } - } - ] - } - ] - }, - { lockedBy: processName } - ] - }, - { failedAt: null } - ] - }; - const whereWithQueue = where; - if (queues.length > 1 || queues[0] !== "*") { - Object.assign(whereWithQueue, { - AND: [...where.AND, { queue: { in: queues } }] - }); - } - const job = await this.accessor.findFirst({ - select: { id: true, attempts: true }, - where: whereWithQueue, - orderBy: [{ priority: "asc" }, { runAt: "asc" }], - take: 1 - }); - if (job) { - const whereWithQueueAndId = Object.assign(whereWithQueue, { - AND: [...whereWithQueue.AND, { id: job.id }] - }); - const { count } = await this.accessor.updateMany({ - where: whereWithQueueAndId, - data: { - lockedAt: /* @__PURE__ */ new Date(), - lockedBy: processName, - attempts: job.attempts + 1 - } - }); - if (count) { - const data = await this.accessor.findFirst({ where: { id: job.id } }); - const { name, path, args } = JSON.parse(data.handler); - return { ...data, name, path, args }; - } - } - return void 0; - } - // Prisma queries are lazily evaluated and only sent to the db when they are - // awaited, so do the await here to ensure they actually run (if the user - // doesn't await the Promise then the queries will never be executed!) - async success({ job, deleteJob }) { - this.logger.debug(`[RedwoodJob] Job ${job.id} success`); - if (deleteJob) { - await this.accessor.delete({ where: { id: job.id } }); - } else { - await this.accessor.update({ - where: { id: job.id }, - data: { - lockedAt: null, - lockedBy: null, - lastError: null, - runAt: null - } - }); - } - } - async error({ job, error }) { - this.logger.debug(`[RedwoodJob] Job ${job.id} failure`); - const data = { - lockedAt: null, - lockedBy: null, - lastError: `${error.message} - -${error.stack}`, - runAt: null - }; - data.runAt = new Date( - (/* @__PURE__ */ new Date()).getTime() + this.backoffMilliseconds(job.attempts) - ); - await this.accessor.update({ - where: { id: job.id }, - data - }); - } - // Job has had too many attempts, it has now permanently failed. - async failure({ job, deleteJob }) { - if (deleteJob) { - await this.accessor.delete({ where: { id: job.id } }); - } else { - await this.accessor.update({ - where: { id: job.id }, - data: { failedAt: /* @__PURE__ */ new Date() } - }); - } - } - // Schedules a job by creating a new record in the background job table - async schedule({ - name, - path, - args, - runAt, - queue, - priority - }) { - await this.accessor.create({ - data: { - handler: JSON.stringify({ name, path, args }), - runAt, - queue, - priority - } - }); - } - async clear() { - await this.accessor.deleteMany(); - } - backoffMilliseconds(attempts) { - return 1e3 * attempts ** 4; - } -} -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - PrismaAdapter -}); diff --git a/packages/jobs/dist/adapters/PrismaAdapter/errors.d.ts b/packages/jobs/dist/adapters/PrismaAdapter/errors.d.ts deleted file mode 100644 index 4f9371334d3a..000000000000 --- a/packages/jobs/dist/adapters/PrismaAdapter/errors.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { RedwoodJobError } from '../../errors'; -export declare class ModelNameError extends RedwoodJobError { - constructor(name: string); -} -//# sourceMappingURL=errors.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/adapters/PrismaAdapter/errors.d.ts.map b/packages/jobs/dist/adapters/PrismaAdapter/errors.d.ts.map deleted file mode 100644 index 3db8b33f981e..000000000000 --- a/packages/jobs/dist/adapters/PrismaAdapter/errors.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../../src/adapters/PrismaAdapter/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAG9C,qBAAa,cAAe,SAAQ,eAAe;gBACrC,IAAI,EAAE,MAAM;CAGzB"} \ No newline at end of file diff --git a/packages/jobs/dist/adapters/PrismaAdapter/errors.js b/packages/jobs/dist/adapters/PrismaAdapter/errors.js deleted file mode 100644 index 2d7968a7cbff..000000000000 --- a/packages/jobs/dist/adapters/PrismaAdapter/errors.js +++ /dev/null @@ -1,33 +0,0 @@ -"use strict"; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); -var errors_exports = {}; -__export(errors_exports, { - ModelNameError: () => ModelNameError -}); -module.exports = __toCommonJS(errors_exports); -var import_errors = require("../../errors"); -class ModelNameError extends import_errors.RedwoodJobError { - constructor(name) { - super(`Model \`${name}\` not found in PrismaClient`); - } -} -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - ModelNameError -}); diff --git a/packages/jobs/dist/bins/rw-jobs-worker.d.ts b/packages/jobs/dist/bins/rw-jobs-worker.d.ts deleted file mode 100644 index 571ae7b230ab..000000000000 --- a/packages/jobs/dist/bins/rw-jobs-worker.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node -export {}; -//# sourceMappingURL=rw-jobs-worker.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/bins/rw-jobs-worker.d.ts.map b/packages/jobs/dist/bins/rw-jobs-worker.d.ts.map deleted file mode 100644 index b1a7d0908b41..000000000000 --- a/packages/jobs/dist/bins/rw-jobs-worker.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"rw-jobs-worker.d.ts","sourceRoot":"","sources":["../../src/bins/rw-jobs-worker.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/packages/jobs/dist/bins/rw-jobs-worker.js b/packages/jobs/dist/bins/rw-jobs-worker.js deleted file mode 100755 index a7fd62fb855c..000000000000 --- a/packages/jobs/dist/bins/rw-jobs-worker.js +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env node -"use strict"; -var __create = Object.create; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __getProtoOf = Object.getPrototypeOf; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( - // If the importer is in node compatibility mode or this is not an ESM - // file that has been converted to a CommonJS file using a Babel- - // compatible transform (i.e. "__esModule" has not been set), then set - // "default" to the CommonJS "module.exports" for node compatibility. - isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, - mod -)); -var import_node_console = __toESM(require("node:console")); -var import_node_process = __toESM(require("node:process")); -var import_helpers = require("yargs/helpers"); -var import_yargs = __toESM(require("yargs/yargs")); -var import_loadEnvFiles = require("@redwoodjs/cli-helpers/loadEnvFiles"); -var import_consts = require("../consts"); -var import_errors = require("../errors"); -var import_loaders = require("../loaders"); -(0, import_loadEnvFiles.loadEnvFiles)(); -const parseArgs = (argv) => { - return (0, import_yargs.default)((0, import_helpers.hideBin)(argv)).usage( - "Starts a single RedwoodJob worker to process background jobs\n\nUsage: $0 [options]" - ).option("index", { - type: "number", - description: "The index of the `workers` array from the exported `jobs` config to use to configure this worker", - default: 0 - }).option("id", { - type: "number", - description: "The worker count id to identify this worker. ie: if you had `count: 2` in your worker config, you would have two workers with ids 0 and 1", - default: 0 - }).option("workoff", { - type: "boolean", - default: false, - description: "Work off all jobs in the queue(s) and exit" - }).option("clear", { - type: "boolean", - default: false, - description: "Remove all jobs in all queues and exit" - }).help().argv; -}; -const setProcessTitle = ({ - id, - queue -}) => { - import_node_process.default.title = `${import_consts.PROCESS_TITLE_PREFIX}.${[queue].flat().join("-")}.${id}`; -}; -const setupSignals = ({ - worker, - logger -}) => { - import_node_process.default.on("SIGINT", () => { - logger.warn( - `[${import_node_process.default.title}] SIGINT received at ${(/* @__PURE__ */ new Date()).toISOString()}, finishing work...` - ); - worker.forever = false; - }); - import_node_process.default.on("SIGTERM", () => { - logger.warn( - `[${import_node_process.default.title}] SIGTERM received at ${(/* @__PURE__ */ new Date()).toISOString()}, exiting now!` - ); - import_node_process.default.exit(0); - }); -}; -const main = async () => { - const { index, id, clear, workoff } = await parseArgs(import_node_process.default.argv); - let manager; - try { - manager = await (0, import_loaders.loadJobsManager)(); - } catch (e) { - import_node_console.default.error(e); - import_node_process.default.exit(1); - } - const workerConfig = manager.workers[index]; - if (!workerConfig) { - throw new import_errors.WorkerConfigIndexNotFoundError(index); - } - const logger = workerConfig.logger ?? manager.logger ?? import_consts.DEFAULT_LOGGER; - logger.warn( - `[${import_node_process.default.title}] Starting work at ${(/* @__PURE__ */ new Date()).toISOString()}...` - ); - setProcessTitle({ id, queue: workerConfig.queue }); - const worker = manager.createWorker({ index, clear, workoff }); - worker.run().then(() => { - logger.info(`[${import_node_process.default.title}] Worker finished, shutting down.`); - import_node_process.default.exit(0); - }); - setupSignals({ worker, logger }); -}; -if (import_node_process.default.env.NODE_ENV !== "test") { - main(); -} diff --git a/packages/jobs/dist/bins/rw-jobs.d.ts b/packages/jobs/dist/bins/rw-jobs.d.ts deleted file mode 100644 index 17bc3f9f2d7f..000000000000 --- a/packages/jobs/dist/bins/rw-jobs.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node -export type NumWorkersConfig = [number, number][]; -//# sourceMappingURL=rw-jobs.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/bins/rw-jobs.d.ts.map b/packages/jobs/dist/bins/rw-jobs.d.ts.map deleted file mode 100644 index b3c025b1d7a2..000000000000 --- a/packages/jobs/dist/bins/rw-jobs.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"rw-jobs.d.ts","sourceRoot":"","sources":["../../src/bins/rw-jobs.ts"],"names":[],"mappings":";AAqBA,MAAM,MAAM,gBAAgB,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAA"} \ No newline at end of file diff --git a/packages/jobs/dist/bins/rw-jobs.js b/packages/jobs/dist/bins/rw-jobs.js deleted file mode 100755 index a93609059424..000000000000 --- a/packages/jobs/dist/bins/rw-jobs.js +++ /dev/null @@ -1,223 +0,0 @@ -#!/usr/bin/env node -"use strict"; -var __create = Object.create; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __getProtoOf = Object.getPrototypeOf; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( - // If the importer is in node compatibility mode or this is not an ESM - // file that has been converted to a CommonJS file using a Babel- - // compatible transform (i.e. "__esModule" has not been set), then set - // "default" to the CommonJS "module.exports" for node compatibility. - isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, - mod -)); -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); -var rw_jobs_exports = {}; -module.exports = __toCommonJS(rw_jobs_exports); -var import_node_child_process = require("node:child_process"); -var import_node_console = __toESM(require("node:console")); -var import_node_path = __toESM(require("node:path")); -var import_node_process = __toESM(require("node:process")); -var import_node_timers = require("node:timers"); -var import_helpers = require("yargs/helpers"); -var import_yargs = __toESM(require("yargs/yargs")); -var import_loadEnvFiles = require("@redwoodjs/cli-helpers/loadEnvFiles"); -var import_consts = require("../consts"); -var import_loaders = require("../loaders"); -(0, import_loadEnvFiles.loadEnvFiles)(); -import_node_process.default.title = "rw-jobs"; -const parseArgs = (argv) => { - const parsed = (0, import_yargs.default)((0, import_helpers.hideBin)(argv)).usage( - "Starts the RedwoodJob runner to process background jobs\n\nUsage: $0 [options]" - ).command("work", "Start a worker and process jobs").command("workoff", "Start a worker and exit after all jobs processed").command("start", "Start workers in daemon mode").command("stop", "Stop any daemonized job workers").command("restart", "Stop and start any daemonized job workers").command("clear", "Clear the job queue").demandCommand(1, "You must specify a mode to start in").example( - "$0 start -n 2", - "Start the job runner with 2 workers in daemon mode" - ).example( - "$0 start -n default:2,email:1", - 'Start the job runner in daemon mode with 2 workers for the "default" queue and 1 for the "email" queue' - ).help().argv; - return { command: parsed._[0] }; -}; -const buildNumWorkers = (config) => { - const workers = []; - config.map((worker, index) => { - for (let id = 0; id < worker.count; id++) { - workers.push([index, id]); - } - }); - return workers; -}; -const startWorkers = ({ - numWorkers, - detach = false, - workoff = false, - logger -}) => { - logger.warn(`Starting ${numWorkers.length} worker(s)...`); - return numWorkers.map(([index, id]) => { - const workerArgs = []; - workerArgs.push("--index", index.toString()); - workerArgs.push("--id", id.toString()); - if (workoff) { - workerArgs.push("--workoff"); - } - const worker = (0, import_node_child_process.fork)(import_node_path.default.join(__dirname, "rw-jobs-worker.js"), workerArgs, { - detached: detach, - stdio: detach ? "ignore" : "inherit", - env: import_node_process.default.env - }); - if (detach) { - worker.unref(); - } else { - worker.on("exit", (_code) => { - }); - } - return worker; - }); -}; -const stopWorkers = async ({ - numWorkers, - signal = "SIGINT", - logger -}) => { - logger.warn( - `Stopping ${numWorkers.length} worker(s) gracefully (${signal})...` - ); - const processIds = await findWorkerProcesses(); - if (processIds.length === 0) { - logger.warn(`No running workers found.`); - return; - } - for (const processId of processIds) { - logger.info(`Stopping process id ${processId}...`); - import_node_process.default.kill(processId, signal); - while ((await findWorkerProcesses(processId)).length) { - await new Promise((resolve) => (0, import_node_timers.setTimeout)(resolve, 250)); - } - } -}; -const clearQueue = ({ logger }) => { - logger.warn(`Starting worker to clear job queue...`); - (0, import_node_child_process.fork)(import_node_path.default.join(__dirname, "rw-jobs-worker.js"), ["--clear"]); -}; -const signalSetup = ({ - workers, - logger -}) => { - let sigtermCount = 0; - import_node_process.default.on("SIGINT", () => { - sigtermCount++; - let message = "SIGINT received: shutting down workers gracefully (press Ctrl-C again to exit immediately)..."; - if (sigtermCount > 1) { - message = "SIGINT received again, exiting immediately..."; - } - logger.info(message); - workers.forEach((worker) => { - if (sigtermCount > 1) { - worker.kill("SIGTERM"); - } else { - worker.kill("SIGINT"); - } - }); - }); -}; -const findWorkerProcesses = async (id) => { - return new Promise(function(resolve, reject) { - const plat = import_node_process.default.platform; - const cmd = plat === "win32" ? "tasklist" : plat === "darwin" ? "ps -ax | grep " + import_consts.PROCESS_TITLE_PREFIX : plat === "linux" ? "ps -A" : ""; - if (cmd === "") { - resolve([]); - } - (0, import_node_child_process.exec)(cmd, function(err, stdout) { - if (err) { - reject(err); - } - const list = stdout.trim().split("\n"); - const matches = list.filter((line) => { - if (plat == "darwin" || plat == "linux") { - return !line.match("grep"); - } - return true; - }); - if (matches.length === 0) { - resolve([]); - } - const pids = matches.map((line) => parseInt(line.split(" ")[0])); - if (id) { - resolve(pids.filter((pid) => pid === id)); - } else { - resolve(pids); - } - }); - }); -}; -const main = async () => { - const { command } = parseArgs(import_node_process.default.argv); - let jobsConfig; - try { - jobsConfig = await (0, import_loaders.loadJobsManager)(); - } catch (e) { - import_node_console.default.error(e); - import_node_process.default.exit(1); - } - const workerConfig = jobsConfig.workers; - const numWorkers = buildNumWorkers(workerConfig); - const logger = jobsConfig.logger ?? import_consts.DEFAULT_LOGGER; - logger.warn(`Starting RedwoodJob Runner at ${(/* @__PURE__ */ new Date()).toISOString()}...`); - switch (command) { - case "start": - startWorkers({ - numWorkers, - detach: true, - logger - }); - return import_node_process.default.exit(0); - case "restart": - await stopWorkers({ numWorkers, signal: "SIGINT", logger }); - startWorkers({ - numWorkers, - detach: true, - logger - }); - return import_node_process.default.exit(0); - case "work": - return signalSetup({ - workers: startWorkers({ - numWorkers, - logger - }), - logger - }); - case "workoff": - return signalSetup({ - workers: startWorkers({ - numWorkers, - workoff: true, - logger - }), - logger - }); - case "stop": - return await stopWorkers({ - numWorkers, - signal: "SIGINT", - logger - }); - case "clear": - return clearQueue({ logger }); - } -}; -if (import_node_process.default.env.NODE_ENV !== "test") { - main(); -} diff --git a/packages/jobs/dist/consts.d.ts b/packages/jobs/dist/consts.d.ts deleted file mode 100644 index c2c722cd2870..000000000000 --- a/packages/jobs/dist/consts.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -export declare const DEFAULT_MAX_ATTEMPTS = 24; -/** 4 hours in seconds */ -export declare const DEFAULT_MAX_RUNTIME = 14400; -/** 5 seconds */ -export declare const DEFAULT_SLEEP_DELAY = 5; -export declare const DEFAULT_DELETE_SUCCESSFUL_JOBS = true; -export declare const DEFAULT_DELETE_FAILED_JOBS = false; -export declare const DEFAULT_LOGGER: Console; -export declare const DEFAULT_QUEUE = "default"; -export declare const DEFAULT_WORK_QUEUE = "*"; -export declare const DEFAULT_PRIORITY = 50; -export declare const DEFAULT_WAIT = 0; -export declare const DEFAULT_WAIT_UNTIL: null; -export declare const PROCESS_TITLE_PREFIX = "rw-jobs-worker"; -export declare const DEFAULT_MODEL_NAME = "BackgroundJob"; -/** - * The name of the exported variable from the jobs config file that contains - * the adapter - */ -export declare const DEFAULT_ADAPTER_NAME = "adapter"; -/** - * The name of the exported variable from the jobs config file that contains - * the logger - */ -export declare const DEFAULT_LOGGER_NAME = "logger"; -//# sourceMappingURL=consts.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/consts.d.ts.map b/packages/jobs/dist/consts.d.ts.map deleted file mode 100644 index c679068cbcd6..000000000000 --- a/packages/jobs/dist/consts.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,oBAAoB,KAAK,CAAA;AACtC,yBAAyB;AACzB,eAAO,MAAM,mBAAmB,QAAS,CAAA;AACzC,gBAAgB;AAChB,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAEpC,eAAO,MAAM,8BAA8B,OAAO,CAAA;AAClD,eAAO,MAAM,0BAA0B,QAAQ,CAAA;AAC/C,eAAO,MAAM,cAAc,SAAU,CAAA;AACrC,eAAO,MAAM,aAAa,YAAY,CAAA;AACtC,eAAO,MAAM,kBAAkB,MAAM,CAAA;AACrC,eAAO,MAAM,gBAAgB,KAAK,CAAA;AAClC,eAAO,MAAM,YAAY,IAAI,CAAA;AAC7B,eAAO,MAAM,kBAAkB,MAAO,CAAA;AACtC,eAAO,MAAM,oBAAoB,mBAAmB,CAAA;AACpD,eAAO,MAAM,kBAAkB,kBAAkB,CAAA;AAEjD;;;GAGG;AACH,eAAO,MAAM,oBAAoB,YAAY,CAAA;AAC7C;;;GAGG;AACH,eAAO,MAAM,mBAAmB,WAAW,CAAA"} \ No newline at end of file diff --git a/packages/jobs/dist/consts.js b/packages/jobs/dist/consts.js deleted file mode 100644 index 483f83772caa..000000000000 --- a/packages/jobs/dist/consts.js +++ /dev/null @@ -1,81 +0,0 @@ -"use strict"; -var __create = Object.create; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __getProtoOf = Object.getPrototypeOf; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( - // If the importer is in node compatibility mode or this is not an ESM - // file that has been converted to a CommonJS file using a Babel- - // compatible transform (i.e. "__esModule" has not been set), then set - // "default" to the CommonJS "module.exports" for node compatibility. - isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, - mod -)); -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); -var consts_exports = {}; -__export(consts_exports, { - DEFAULT_ADAPTER_NAME: () => DEFAULT_ADAPTER_NAME, - DEFAULT_DELETE_FAILED_JOBS: () => DEFAULT_DELETE_FAILED_JOBS, - DEFAULT_DELETE_SUCCESSFUL_JOBS: () => DEFAULT_DELETE_SUCCESSFUL_JOBS, - DEFAULT_LOGGER: () => DEFAULT_LOGGER, - DEFAULT_LOGGER_NAME: () => DEFAULT_LOGGER_NAME, - DEFAULT_MAX_ATTEMPTS: () => DEFAULT_MAX_ATTEMPTS, - DEFAULT_MAX_RUNTIME: () => DEFAULT_MAX_RUNTIME, - DEFAULT_MODEL_NAME: () => DEFAULT_MODEL_NAME, - DEFAULT_PRIORITY: () => DEFAULT_PRIORITY, - DEFAULT_QUEUE: () => DEFAULT_QUEUE, - DEFAULT_SLEEP_DELAY: () => DEFAULT_SLEEP_DELAY, - DEFAULT_WAIT: () => DEFAULT_WAIT, - DEFAULT_WAIT_UNTIL: () => DEFAULT_WAIT_UNTIL, - DEFAULT_WORK_QUEUE: () => DEFAULT_WORK_QUEUE, - PROCESS_TITLE_PREFIX: () => PROCESS_TITLE_PREFIX -}); -module.exports = __toCommonJS(consts_exports); -var import_node_console = __toESM(require("node:console")); -const DEFAULT_MAX_ATTEMPTS = 24; -const DEFAULT_MAX_RUNTIME = 14400; -const DEFAULT_SLEEP_DELAY = 5; -const DEFAULT_DELETE_SUCCESSFUL_JOBS = true; -const DEFAULT_DELETE_FAILED_JOBS = false; -const DEFAULT_LOGGER = import_node_console.default; -const DEFAULT_QUEUE = "default"; -const DEFAULT_WORK_QUEUE = "*"; -const DEFAULT_PRIORITY = 50; -const DEFAULT_WAIT = 0; -const DEFAULT_WAIT_UNTIL = null; -const PROCESS_TITLE_PREFIX = "rw-jobs-worker"; -const DEFAULT_MODEL_NAME = "BackgroundJob"; -const DEFAULT_ADAPTER_NAME = "adapter"; -const DEFAULT_LOGGER_NAME = "logger"; -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - DEFAULT_ADAPTER_NAME, - DEFAULT_DELETE_FAILED_JOBS, - DEFAULT_DELETE_SUCCESSFUL_JOBS, - DEFAULT_LOGGER, - DEFAULT_LOGGER_NAME, - DEFAULT_MAX_ATTEMPTS, - DEFAULT_MAX_RUNTIME, - DEFAULT_MODEL_NAME, - DEFAULT_PRIORITY, - DEFAULT_QUEUE, - DEFAULT_SLEEP_DELAY, - DEFAULT_WAIT, - DEFAULT_WAIT_UNTIL, - DEFAULT_WORK_QUEUE, - PROCESS_TITLE_PREFIX -}); diff --git a/packages/jobs/dist/core/Executor.d.ts b/packages/jobs/dist/core/Executor.d.ts deleted file mode 100644 index c6fbf4d417de..000000000000 --- a/packages/jobs/dist/core/Executor.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { BaseAdapter } from '../adapters/BaseAdapter/BaseAdapter'; -import type { BaseJob, BasicLogger } from '../types'; -interface Options { - adapter: BaseAdapter; - job: BaseJob; - logger?: BasicLogger; - maxAttempts?: number; - deleteFailedJobs?: boolean; - deleteSuccessfulJobs?: boolean; -} -export declare const DEFAULTS: { - logger: Console; - maxAttempts: number; - deleteFailedJobs: boolean; - deleteSuccessfulJobs: boolean; -}; -export declare class Executor { - options: Required; - adapter: Options['adapter']; - logger: NonNullable; - job: BaseJob; - maxAttempts: NonNullable; - deleteFailedJobs: NonNullable; - deleteSuccessfulJobs: NonNullable; - constructor(options: Options); - get jobIdentifier(): string; - perform(): Promise; -} -export {}; -//# sourceMappingURL=Executor.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/core/Executor.d.ts.map b/packages/jobs/dist/core/Executor.d.ts.map deleted file mode 100644 index b9247747b741..000000000000 --- a/packages/jobs/dist/core/Executor.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"Executor.d.ts","sourceRoot":"","sources":["../../src/core/Executor.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qCAAqC,CAAA;AAStE,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;AAEpD,UAAU,OAAO;IACf,OAAO,EAAE,WAAW,CAAA;IACpB,GAAG,EAAE,OAAO,CAAA;IACZ,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B,oBAAoB,CAAC,EAAE,OAAO,CAAA;CAC/B;AAED,eAAO,MAAM,QAAQ;;;;;CAKpB,CAAA;AAED,qBAAa,QAAQ;IACnB,OAAO,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;IAC1B,OAAO,EAAE,OAAO,CAAC,SAAS,CAAC,CAAA;IAC3B,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAA;IACtC,GAAG,EAAE,OAAO,CAAA;IACZ,WAAW,EAAE,WAAW,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,CAAA;IAChD,gBAAgB,EAAE,WAAW,CAAC,OAAO,CAAC,kBAAkB,CAAC,CAAC,CAAA;IAC1D,oBAAoB,EAAE,WAAW,CAAC,OAAO,CAAC,sBAAsB,CAAC,CAAC,CAAA;gBAEtD,OAAO,EAAE,OAAO;IAmB5B,IAAI,aAAa,WAEhB;IAEK,OAAO;CAkCd"} \ No newline at end of file diff --git a/packages/jobs/dist/core/Executor.js b/packages/jobs/dist/core/Executor.js deleted file mode 100644 index 5efccc097857..000000000000 --- a/packages/jobs/dist/core/Executor.js +++ /dev/null @@ -1,95 +0,0 @@ -"use strict"; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); -var Executor_exports = {}; -__export(Executor_exports, { - DEFAULTS: () => DEFAULTS, - Executor: () => Executor -}); -module.exports = __toCommonJS(Executor_exports); -var import_consts = require("../consts"); -var import_errors = require("../errors"); -var import_loaders = require("../loaders"); -const DEFAULTS = { - logger: import_consts.DEFAULT_LOGGER, - maxAttempts: import_consts.DEFAULT_MAX_ATTEMPTS, - deleteFailedJobs: import_consts.DEFAULT_DELETE_FAILED_JOBS, - deleteSuccessfulJobs: import_consts.DEFAULT_DELETE_SUCCESSFUL_JOBS -}; -class Executor { - options; - adapter; - logger; - job; - maxAttempts; - deleteFailedJobs; - deleteSuccessfulJobs; - constructor(options) { - this.options = { ...DEFAULTS, ...options }; - if (!this.options.adapter) { - throw new import_errors.AdapterRequiredError(); - } - if (!this.options.job) { - throw new import_errors.JobRequiredError(); - } - this.adapter = this.options.adapter; - this.logger = this.options.logger; - this.job = this.options.job; - this.maxAttempts = this.options.maxAttempts; - this.deleteFailedJobs = this.options.deleteFailedJobs; - this.deleteSuccessfulJobs = this.options.deleteSuccessfulJobs; - } - get jobIdentifier() { - return `${this.job.id} (${this.job.path}:${this.job.name})`; - } - async perform() { - this.logger.info(`[RedwoodJob] Started job ${this.jobIdentifier}`); - try { - const job = await (0, import_loaders.loadJob)({ name: this.job.name, path: this.job.path }); - await job.perform(...this.job.args); - await this.adapter.success({ - job: this.job, - deleteJob: import_consts.DEFAULT_DELETE_SUCCESSFUL_JOBS - }); - } catch (error) { - this.logger.error( - `[RedwoodJob] Error in job ${this.jobIdentifier}: ${error.message}` - ); - this.logger.error(error.stack); - await this.adapter.error({ - job: this.job, - error - }); - if (this.job.attempts >= this.maxAttempts) { - this.logger.warn( - this.job, - `[RedwoodJob] Failed job ${this.jobIdentifier}: reached max attempts (${this.maxAttempts})` - ); - await this.adapter.failure({ - job: this.job, - deleteJob: this.deleteFailedJobs - }); - } - } - } -} -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - DEFAULTS, - Executor -}); diff --git a/packages/jobs/dist/core/JobManager.d.ts b/packages/jobs/dist/core/JobManager.d.ts deleted file mode 100644 index cab19efd396c..000000000000 --- a/packages/jobs/dist/core/JobManager.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Adapters, BasicLogger, CreateSchedulerConfig, Job, JobDefinition, JobManagerConfig, ScheduleJobOptions, WorkerConfig } from '../types'; -import type { WorkerOptions } from './Worker'; -import { Worker } from './Worker'; -export interface CreateWorkerArgs { - index: number; - workoff: WorkerOptions['workoff']; - clear: WorkerOptions['clear']; -} -export declare class JobManager { - adapters: TAdapters; - queues: TQueues; - logger: TLogger; - workers: WorkerConfig[]; - constructor(config: JobManagerConfig); - createScheduler(schedulerConfig: CreateSchedulerConfig): >(job: T, jobArgs?: Parameters, jobOptions?: ScheduleJobOptions) => Promise; - createJob(jobDefinition: JobDefinition): Job; - createWorker({ index, workoff, clear }: CreateWorkerArgs): Worker; -} -//# sourceMappingURL=JobManager.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/core/JobManager.d.ts.map b/packages/jobs/dist/core/JobManager.d.ts.map deleted file mode 100644 index bdf9c76d6ed0..000000000000 --- a/packages/jobs/dist/core/JobManager.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"JobManager.d.ts","sourceRoot":"","sources":["../../src/core/JobManager.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,QAAQ,EACR,WAAW,EACX,qBAAqB,EACrB,GAAG,EACH,aAAa,EACb,gBAAgB,EAChB,kBAAkB,EAClB,YAAY,EACb,MAAM,UAAU,CAAA;AAGjB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAC7C,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAEjC,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,aAAa,CAAC,SAAS,CAAC,CAAA;IACjC,KAAK,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;CAC9B;AAED,qBAAa,UAAU,CACrB,SAAS,SAAS,QAAQ,EAC1B,OAAO,SAAS,MAAM,EAAE,EACxB,OAAO,SAAS,WAAW;IAE3B,QAAQ,EAAE,SAAS,CAAA;IACnB,MAAM,EAAE,OAAO,CAAA;IACf,MAAM,EAAE,OAAO,CAAA;IACf,OAAO,EAAE,YAAY,CAAC,SAAS,EAAE,OAAO,CAAC,EAAE,CAAA;gBAE/B,MAAM,EAAE,gBAAgB,CAAC,SAAS,EAAE,OAAO,EAAE,OAAO,CAAC;IAOjE,eAAe,CAAC,eAAe,EAAE,qBAAqB,CAAC,SAAS,CAAC,IAMvD,CAAC,SAAS,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAC9B,CAAC,YACI,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,eACrB,kBAAkB;IAMnC,SAAS,CAAC,KAAK,SAAS,OAAO,EAAE,EAC/B,aAAa,EAAE,aAAa,CAAC,OAAO,EAAE,KAAK,CAAC,GAC3C,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC;IAOtB,YAAY,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE,gBAAgB;CAoBzD"} \ No newline at end of file diff --git a/packages/jobs/dist/core/JobManager.js b/packages/jobs/dist/core/JobManager.js deleted file mode 100644 index 61d178d5bad7..000000000000 --- a/packages/jobs/dist/core/JobManager.js +++ /dev/null @@ -1,73 +0,0 @@ -"use strict"; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); -var JobManager_exports = {}; -__export(JobManager_exports, { - JobManager: () => JobManager -}); -module.exports = __toCommonJS(JobManager_exports); -var import_errors = require("../errors"); -var import_Scheduler = require("./Scheduler"); -var import_Worker = require("./Worker"); -class JobManager { - adapters; - queues; - logger; - workers; - constructor(config) { - this.adapters = config.adapters; - this.queues = config.queues; - this.logger = config.logger; - this.workers = config.workers; - } - createScheduler(schedulerConfig) { - const scheduler = new import_Scheduler.Scheduler({ - adapter: this.adapters[schedulerConfig.adapter], - logger: this.logger - }); - return (job, jobArgs, jobOptions) => { - return scheduler.schedule({ job, jobArgs, jobOptions }); - }; - } - createJob(jobDefinition) { - return jobDefinition; - } - createWorker({ index, workoff, clear }) { - const config = this.workers[index]; - const adapter = this.adapters[config.adapter]; - if (!adapter) { - throw new import_errors.AdapterNotFoundError(config.adapter.toString()); - } - return new import_Worker.Worker({ - adapter: this.adapters[config.adapter], - logger: config.logger || this.logger, - maxAttempts: config.maxAttempts, - maxRuntime: config.maxRuntime, - sleepDelay: config.sleepDelay, - deleteFailedJobs: config.deleteFailedJobs, - processName: process.title, - queues: [config.queue].flat(), - workoff, - clear - }); - } -} -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - JobManager -}); diff --git a/packages/jobs/dist/core/Scheduler.d.ts b/packages/jobs/dist/core/Scheduler.d.ts deleted file mode 100644 index a2bc990c0de8..000000000000 --- a/packages/jobs/dist/core/Scheduler.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { BaseAdapter, SchedulePayload } from '../adapters/BaseAdapter/BaseAdapter'; -import type { BasicLogger, Job, ScheduleJobOptions } from '../types'; -interface SchedulerConfig { - adapter: TAdapter; - logger?: BasicLogger; -} -export declare class Scheduler { - adapter: TAdapter; - logger: NonNullable['logger']>; - constructor({ adapter, logger }: SchedulerConfig); - computeRunAt({ wait, waitUntil }: { - wait: number; - waitUntil: Date | null; - }): Date; - buildPayload>(job: T, args?: Parameters, options?: ScheduleJobOptions): SchedulePayload; - schedule>({ job, jobArgs, jobOptions, }: { - job: T; - jobArgs?: Parameters; - jobOptions?: ScheduleJobOptions; - }): Promise; -} -export {}; -//# sourceMappingURL=Scheduler.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/core/Scheduler.d.ts.map b/packages/jobs/dist/core/Scheduler.d.ts.map deleted file mode 100644 index b70778a2f747..000000000000 --- a/packages/jobs/dist/core/Scheduler.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"Scheduler.d.ts","sourceRoot":"","sources":["../../src/core/Scheduler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,WAAW,EACX,eAAe,EAChB,MAAM,qCAAqC,CAAA;AAY5C,OAAO,KAAK,EAAE,WAAW,EAAE,GAAG,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAA;AAEpE,UAAU,eAAe,CAAC,QAAQ,SAAS,WAAW;IACpD,OAAO,EAAE,QAAQ,CAAA;IACjB,MAAM,CAAC,EAAE,WAAW,CAAA;CACrB;AAED,qBAAa,SAAS,CAAC,QAAQ,SAAS,WAAW;IACjD,OAAO,EAAE,QAAQ,CAAA;IACjB,MAAM,EAAE,WAAW,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAA;gBAE5C,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,eAAe,CAAC,QAAQ,CAAC;IAS1D,YAAY,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,IAAI,GAAG,IAAI,CAAA;KAAE;IAU1E,YAAY,CAAC,CAAC,SAAS,GAAG,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE,CAAC,EAC7C,GAAG,EAAE,CAAC,EACN,IAAI,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,EAC/B,OAAO,CAAC,EAAE,kBAAkB,GAC3B,eAAe;IAoBZ,QAAQ,CAAC,CAAC,SAAS,GAAG,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE,CAAC,EAAE,EACjD,GAAG,EACH,OAAO,EACP,UAAU,GACX,EAAE;QACD,GAAG,EAAE,CAAC,CAAA;QACN,OAAO,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAA;QAClC,UAAU,CAAC,EAAE,kBAAkB,CAAA;KAChC;CAeF"} \ No newline at end of file diff --git a/packages/jobs/dist/core/Scheduler.js b/packages/jobs/dist/core/Scheduler.js deleted file mode 100644 index d427aa872a1e..000000000000 --- a/packages/jobs/dist/core/Scheduler.js +++ /dev/null @@ -1,83 +0,0 @@ -"use strict"; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); -var Scheduler_exports = {}; -__export(Scheduler_exports, { - Scheduler: () => Scheduler -}); -module.exports = __toCommonJS(Scheduler_exports); -var import_consts = require("../consts"); -var import_errors = require("../errors"); -class Scheduler { - adapter; - logger; - constructor({ adapter, logger }) { - this.logger = logger ?? import_consts.DEFAULT_LOGGER; - this.adapter = adapter; - if (!this.adapter) { - throw new import_errors.AdapterNotConfiguredError(); - } - } - computeRunAt({ wait, waitUntil }) { - if (wait && wait > 0) { - return new Date(Date.now() + wait * 1e3); - } else if (waitUntil) { - return waitUntil; - } else { - return /* @__PURE__ */ new Date(); - } - } - buildPayload(job, args, options) { - const queue = job.queue; - const priority = job.priority ?? import_consts.DEFAULT_PRIORITY; - const wait = options?.wait ?? import_consts.DEFAULT_WAIT; - const waitUntil = options?.waitUntil ?? import_consts.DEFAULT_WAIT_UNTIL; - if (!queue) { - throw new import_errors.QueueNotDefinedError(); - } - return { - name: job.name, - path: job.path, - args: args ?? [], - runAt: this.computeRunAt({ wait, waitUntil }), - queue, - priority - }; - } - async schedule({ - job, - jobArgs, - jobOptions - }) { - const payload = this.buildPayload(job, jobArgs, jobOptions); - this.logger.info(payload, `[RedwoodJob] Scheduling ${job.name}`); - try { - await this.adapter.schedule(payload); - return true; - } catch (e) { - throw new import_errors.SchedulingError( - `[RedwoodJob] Exception when scheduling ${payload.name}`, - e - ); - } - } -} -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - Scheduler -}); diff --git a/packages/jobs/dist/core/Worker.d.ts b/packages/jobs/dist/core/Worker.d.ts deleted file mode 100644 index c0b634b1838b..000000000000 --- a/packages/jobs/dist/core/Worker.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { BaseAdapter } from '../adapters/BaseAdapter/BaseAdapter'; -import type { BasicLogger } from '../types'; -export interface WorkerOptions { - adapter: BaseAdapter; - processName: string; - queues: string[]; - logger?: BasicLogger; - clear?: boolean; - maxAttempts?: number; - maxRuntime?: number; - deleteSuccessfulJobs?: boolean; - deleteFailedJobs?: boolean; - sleepDelay?: number; - workoff?: boolean; - forever?: boolean; -} -type CompleteOptions = Required; -export declare class Worker { - #private; - options: CompleteOptions; - adapter: CompleteOptions['adapter']; - logger: CompleteOptions['logger']; - clear: CompleteOptions['clear']; - processName: CompleteOptions['processName']; - queues: CompleteOptions['queues']; - maxAttempts: CompleteOptions['maxAttempts']; - maxRuntime: CompleteOptions['maxRuntime']; - deleteSuccessfulJobs: CompleteOptions['deleteSuccessfulJobs']; - deleteFailedJobs: CompleteOptions['deleteFailedJobs']; - sleepDelay: CompleteOptions['sleepDelay']; - forever: CompleteOptions['forever']; - workoff: CompleteOptions['workoff']; - lastCheckTime: Date; - constructor(options: WorkerOptions); - run(): Promise; - get queueNames(): string; -} -export {}; -//# sourceMappingURL=Worker.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/core/Worker.d.ts.map b/packages/jobs/dist/core/Worker.d.ts.map deleted file mode 100644 index f6056b445851..000000000000 --- a/packages/jobs/dist/core/Worker.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"Worker.d.ts","sourceRoot":"","sources":["../../src/core/Worker.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qCAAqC,CAAA;AAUtE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;AAI3C,MAAM,WAAW,aAAa;IAE5B,OAAO,EAAE,WAAW,CAAA;IACpB,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,MAAM,EAAE,CAAA;IAEhB,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,oBAAoB,CAAC,EAAE,OAAO,CAAA;IAC9B,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,OAAO,CAAA;IAGjB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,KAAK,eAAe,GAAG,QAAQ,CAAC,aAAa,CAAC,CAAA;AAc9C,qBAAa,MAAM;;IACjB,OAAO,EAAE,eAAe,CAAA;IACxB,OAAO,EAAE,eAAe,CAAC,SAAS,CAAC,CAAA;IACnC,MAAM,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAA;IACjC,KAAK,EAAE,eAAe,CAAC,OAAO,CAAC,CAAA;IAC/B,WAAW,EAAE,eAAe,CAAC,aAAa,CAAC,CAAA;IAC3C,MAAM,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAA;IACjC,WAAW,EAAE,eAAe,CAAC,aAAa,CAAC,CAAA;IAC3C,UAAU,EAAE,eAAe,CAAC,YAAY,CAAC,CAAA;IACzC,oBAAoB,EAAE,eAAe,CAAC,sBAAsB,CAAC,CAAA;IAC7D,gBAAgB,EAAE,eAAe,CAAC,kBAAkB,CAAC,CAAA;IACrD,UAAU,EAAE,eAAe,CAAC,YAAY,CAAC,CAAA;IACzC,OAAO,EAAE,eAAe,CAAC,SAAS,CAAC,CAAA;IACnC,OAAO,EAAE,eAAe,CAAC,SAAS,CAAC,CAAA;IACnC,aAAa,EAAE,IAAI,CAAA;gBAEP,OAAO,EAAE,aAAa;IA2DlC,GAAG;IAQH,IAAI,UAAU,WAMb;CAkDF"} \ No newline at end of file diff --git a/packages/jobs/dist/core/Worker.js b/packages/jobs/dist/core/Worker.js deleted file mode 100644 index 4a2dcddccf1c..000000000000 --- a/packages/jobs/dist/core/Worker.js +++ /dev/null @@ -1,134 +0,0 @@ -"use strict"; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); -var Worker_exports = {}; -__export(Worker_exports, { - Worker: () => Worker -}); -module.exports = __toCommonJS(Worker_exports); -var import_node_timers = require("node:timers"); -var import_consts = require("../consts"); -var import_errors = require("../errors"); -var import_Executor = require("./Executor"); -const DEFAULT_OPTIONS = { - logger: import_consts.DEFAULT_LOGGER, - clear: false, - maxAttempts: import_consts.DEFAULT_MAX_ATTEMPTS, - maxRuntime: import_consts.DEFAULT_MAX_RUNTIME, - deleteSuccessfulJobs: import_consts.DEFAULT_DELETE_SUCCESSFUL_JOBS, - deleteFailedJobs: import_consts.DEFAULT_DELETE_FAILED_JOBS, - sleepDelay: import_consts.DEFAULT_SLEEP_DELAY, - workoff: false, - forever: true -}; -class Worker { - options; - adapter; - logger; - clear; - processName; - queues; - maxAttempts; - maxRuntime; - deleteSuccessfulJobs; - deleteFailedJobs; - sleepDelay; - forever; - workoff; - lastCheckTime; - constructor(options) { - this.options = { ...DEFAULT_OPTIONS, ...options }; - if (!options?.adapter) { - throw new import_errors.AdapterRequiredError(); - } - if (!options?.queues || options.queues.length === 0) { - throw new import_errors.QueuesRequiredError(); - } - this.adapter = this.options.adapter; - this.logger = this.options.logger; - this.clear = this.options.clear; - this.processName = this.options.processName; - this.queues = this.options.queues; - this.maxAttempts = this.options.maxAttempts; - this.maxRuntime = this.options.maxRuntime; - this.deleteSuccessfulJobs = this.options.deleteSuccessfulJobs; - this.deleteFailedJobs = this.options.deleteFailedJobs; - this.sleepDelay = this.options.sleepDelay * 1e3; - this.forever = this.options.forever; - this.workoff = this.options.workoff; - this.lastCheckTime = /* @__PURE__ */ new Date(); - } - // Workers run forever unless: - // `this.forever` to false (loop only runs once, then exits) - // `this.workoff` is true (run all jobs in the queue, then exits) - run() { - if (this.clear) { - return this.#clearQueue(); - } else { - return this.#work(); - } - } - get queueNames() { - if (this.queues.length === 1 && this.queues[0] === "*") { - return "all (*)"; - } else { - return this.queues.join(", "); - } - } - async #clearQueue() { - return await this.adapter.clear(); - } - async #work() { - do { - this.lastCheckTime = /* @__PURE__ */ new Date(); - this.logger.debug( - `[${this.processName}] Checking for jobs in ${this.queueNames} queues...` - ); - const job = await this.adapter.find({ - processName: this.processName, - maxRuntime: this.maxRuntime, - queues: this.queues - }); - if (job) { - await new import_Executor.Executor({ - adapter: this.adapter, - logger: this.logger, - job, - maxAttempts: this.maxAttempts, - deleteSuccessfulJobs: this.deleteSuccessfulJobs, - deleteFailedJobs: this.deleteFailedJobs - }).perform(); - } else if (this.workoff) { - break; - } - if (!job && this.forever) { - const millsSinceLastCheck = (/* @__PURE__ */ new Date()).getTime() - this.lastCheckTime.getTime(); - if (millsSinceLastCheck < this.sleepDelay) { - await this.#wait(this.sleepDelay - millsSinceLastCheck); - } - } - } while (this.forever); - } - #wait(ms) { - return new Promise((resolve) => (0, import_node_timers.setTimeout)(resolve, ms)); - } -} -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - Worker -}); diff --git a/packages/jobs/dist/errors.d.ts b/packages/jobs/dist/errors.d.ts deleted file mode 100644 index 01707209f04f..000000000000 --- a/packages/jobs/dist/errors.d.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Parent class for any RedwoodJob-related error - */ -export declare class RedwoodJobError extends Error { - constructor(message: string); -} -/** - * Thrown when trying to configure a scheduler without an adapter - */ -export declare class AdapterNotConfiguredError extends RedwoodJobError { - constructor(); -} -/** - * Thrown when the Worker or Executor is instantiated without an adapter - */ -export declare class AdapterRequiredError extends RedwoodJobError { - constructor(); -} -/** - * Thrown when the Worker is instantiated without an array of queues - */ -export declare class QueuesRequiredError extends RedwoodJobError { - constructor(); -} -/** - * Thrown when the Executor is instantiated without a job - */ -export declare class JobRequiredError extends RedwoodJobError { - constructor(); -} -/** - * Thrown when a job with the given handler is not found in the filesystem - */ -export declare class JobNotFoundError extends RedwoodJobError { - constructor(name: string); -} -/** - * Thrown when a job file exists, but the export does not match the filename - */ -export declare class JobExportNotFoundError extends RedwoodJobError { - constructor(name: string); -} -/** - * Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js|ts and - * the file does not exist - */ -export declare class JobsLibNotFoundError extends RedwoodJobError { - constructor(); -} -/** - * Thrown when the runner tries to import `adapter` from api/src/lib/jobs.js|ts - */ -export declare class AdapterNotFoundError extends RedwoodJobError { - constructor(name: string); -} -/** - * Thrown when the runner tries to import `logger` from api/src/lib/jobs.js|ts - */ -export declare class LoggerNotFoundError extends RedwoodJobError { - constructor(name: string); -} -/** - * Thrown when the runner tries to import `workerConfig` from api/src/lib/jobs.js|ts - */ -export declare class WorkerConfigNotFoundError extends RedwoodJobError { - constructor(name: string); -} -/** - * Parent class for any job error where we want to wrap the underlying error - * in our own. Use by extending this class and passing the original error to - * the constructor: - * - * ```typescript - * try { - * throw new Error('Generic error') - * } catch (e) { - * throw new RethrowJobError('Custom Error Message', e) - * } - * ``` - */ -export declare class RethrownJobError extends RedwoodJobError { - originalError: Error; - stackBeforeRethrow: string | undefined; - constructor(message: string, error: Error); -} -/** - * Thrown when there is an error scheduling a job, wraps the underlying error - */ -export declare class SchedulingError extends RethrownJobError { - constructor(message: string, error: Error); -} -/** - * Thrown when there is an error performing a job, wraps the underlying error - */ -export declare class PerformError extends RethrownJobError { - constructor(message: string, error: Error); -} -export declare class QueueNotDefinedError extends RedwoodJobError { - constructor(); -} -export declare class WorkerConfigIndexNotFoundError extends RedwoodJobError { - constructor(index: number); -} -//# sourceMappingURL=errors.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/errors.d.ts.map b/packages/jobs/dist/errors.d.ts.map deleted file mode 100644 index 3f39d1fbf48c..000000000000 --- a/packages/jobs/dist/errors.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,qBAAa,eAAgB,SAAQ,KAAK;gBAC5B,OAAO,EAAE,MAAM;CAI5B;AAED;;GAEG;AACH,qBAAa,yBAA0B,SAAQ,eAAe;;CAI7D;AAED;;GAEG;AACH,qBAAa,oBAAqB,SAAQ,eAAe;;CAIxD;AAED;;GAEG;AACH,qBAAa,mBAAoB,SAAQ,eAAe;;CAIvD;AAED;;GAEG;AACH,qBAAa,gBAAiB,SAAQ,eAAe;;CAIpD;AAED;;GAEG;AACH,qBAAa,gBAAiB,SAAQ,eAAe;gBACvC,IAAI,EAAE,MAAM;CAGzB;AAED;;GAEG;AACH,qBAAa,sBAAuB,SAAQ,eAAe;gBAC7C,IAAI,EAAE,MAAM;CAGzB;AAED;;;GAGG;AACH,qBAAa,oBAAqB,SAAQ,eAAe;;CAMxD;AAED;;GAEG;AACH,qBAAa,oBAAqB,SAAQ,eAAe;gBAC3C,IAAI,EAAE,MAAM;CAKzB;AAED;;GAEG;AACH,qBAAa,mBAAoB,SAAQ,eAAe;gBAC1C,IAAI,EAAE,MAAM;CAKzB;AAED;;GAEG;AACH,qBAAa,yBAA0B,SAAQ,eAAe;gBAChD,IAAI,EAAE,MAAM;CAGzB;AAED;;;;;;;;;;;;GAYG;AACH,qBAAa,gBAAiB,SAAQ,eAAe;IACnD,aAAa,EAAE,KAAK,CAAA;IACpB,kBAAkB,EAAE,MAAM,GAAG,SAAS,CAAA;gBAE1B,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAqB1C;AAED;;GAEG;AACH,qBAAa,eAAgB,SAAQ,gBAAgB;gBACvC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAG1C;AAED;;GAEG;AACH,qBAAa,YAAa,SAAQ,gBAAgB;gBACpC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAG1C;AAED,qBAAa,oBAAqB,SAAQ,eAAe;;CAIxD;AAED,qBAAa,8BAA+B,SAAQ,eAAe;gBACrD,KAAK,EAAE,MAAM;CAG1B"} \ No newline at end of file diff --git a/packages/jobs/dist/errors.js b/packages/jobs/dist/errors.js deleted file mode 100644 index 699f8ca67428..000000000000 --- a/packages/jobs/dist/errors.js +++ /dev/null @@ -1,156 +0,0 @@ -"use strict"; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); -var errors_exports = {}; -__export(errors_exports, { - AdapterNotConfiguredError: () => AdapterNotConfiguredError, - AdapterNotFoundError: () => AdapterNotFoundError, - AdapterRequiredError: () => AdapterRequiredError, - JobExportNotFoundError: () => JobExportNotFoundError, - JobNotFoundError: () => JobNotFoundError, - JobRequiredError: () => JobRequiredError, - JobsLibNotFoundError: () => JobsLibNotFoundError, - LoggerNotFoundError: () => LoggerNotFoundError, - PerformError: () => PerformError, - QueueNotDefinedError: () => QueueNotDefinedError, - QueuesRequiredError: () => QueuesRequiredError, - RedwoodJobError: () => RedwoodJobError, - RethrownJobError: () => RethrownJobError, - SchedulingError: () => SchedulingError, - WorkerConfigIndexNotFoundError: () => WorkerConfigIndexNotFoundError, - WorkerConfigNotFoundError: () => WorkerConfigNotFoundError -}); -module.exports = __toCommonJS(errors_exports); -const JOBS_CONFIG_FILENAME = "jobs.ts/js"; -class RedwoodJobError extends Error { - constructor(message) { - super(message); - this.name = this.constructor.name; - } -} -class AdapterNotConfiguredError extends RedwoodJobError { - constructor() { - super("No adapter configured for the job scheduler"); - } -} -class AdapterRequiredError extends RedwoodJobError { - constructor() { - super("`adapter` is required to perform a job"); - } -} -class QueuesRequiredError extends RedwoodJobError { - constructor() { - super("`queues` is required to find a job to run"); - } -} -class JobRequiredError extends RedwoodJobError { - constructor() { - super("`job` is required to perform a job"); - } -} -class JobNotFoundError extends RedwoodJobError { - constructor(name) { - super(`Job \`${name}\` not found in the filesystem`); - } -} -class JobExportNotFoundError extends RedwoodJobError { - constructor(name) { - super(`Job file \`${name}\` does not export a class with the same name`); - } -} -class JobsLibNotFoundError extends RedwoodJobError { - constructor() { - super( - `api/src/lib/${JOBS_CONFIG_FILENAME} not found. Run \`yarn rw setup jobs\` to create this file and configure background jobs` - ); - } -} -class AdapterNotFoundError extends RedwoodJobError { - constructor(name) { - super( - `api/src/lib/${JOBS_CONFIG_FILENAME} does not export an adapter named \`${name}\`` - ); - } -} -class LoggerNotFoundError extends RedwoodJobError { - constructor(name) { - super( - `api/src/lib/${JOBS_CONFIG_FILENAME} does not export a logger named \`${name}\`` - ); - } -} -class WorkerConfigNotFoundError extends RedwoodJobError { - constructor(name) { - super(`api/src/lib/#{JOBS_CONFIG_FILENAME} does not export \`${name}\``); - } -} -class RethrownJobError extends RedwoodJobError { - originalError; - stackBeforeRethrow; - constructor(message, error) { - super(message); - if (!error) { - throw new Error( - "RethrownJobError requires a message and existing error object" - ); - } - this.originalError = error; - this.stackBeforeRethrow = this.stack; - const messageLines = (this.message.match(/\n/g) || []).length + 1; - this.stack = this.stack?.split("\n").slice(0, messageLines + 1).join("\n") + "\n" + error.stack; - } -} -class SchedulingError extends RethrownJobError { - constructor(message, error) { - super(message, error); - } -} -class PerformError extends RethrownJobError { - constructor(message, error) { - super(message, error); - } -} -class QueueNotDefinedError extends RedwoodJobError { - constructor() { - super("Scheduler requires a named `queue` to place jobs in"); - } -} -class WorkerConfigIndexNotFoundError extends RedwoodJobError { - constructor(index) { - super(`Worker index ${index} not found in jobs config`); - } -} -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - AdapterNotConfiguredError, - AdapterNotFoundError, - AdapterRequiredError, - JobExportNotFoundError, - JobNotFoundError, - JobRequiredError, - JobsLibNotFoundError, - LoggerNotFoundError, - PerformError, - QueueNotDefinedError, - QueuesRequiredError, - RedwoodJobError, - RethrownJobError, - SchedulingError, - WorkerConfigIndexNotFoundError, - WorkerConfigNotFoundError -}); diff --git a/packages/jobs/dist/index.d.ts b/packages/jobs/dist/index.d.ts deleted file mode 100644 index 989b1a856416..000000000000 --- a/packages/jobs/dist/index.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './errors'; -export { JobManager } from './core/JobManager'; -export { Executor } from './core/Executor'; -export { Worker } from './core/Worker'; -export { BaseAdapter } from './adapters/BaseAdapter/BaseAdapter'; -export { PrismaAdapter } from './adapters/PrismaAdapter/PrismaAdapter'; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/index.d.ts.map b/packages/jobs/dist/index.d.ts.map deleted file mode 100644 index 5ec20d29e766..000000000000 --- a/packages/jobs/dist/index.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAA;AAExB,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAC9C,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAC1C,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAA;AAEtC,OAAO,EAAE,WAAW,EAAE,MAAM,oCAAoC,CAAA;AAChE,OAAO,EAAE,aAAa,EAAE,MAAM,wCAAwC,CAAA"} \ No newline at end of file diff --git a/packages/jobs/dist/index.js b/packages/jobs/dist/index.js deleted file mode 100644 index 18b7a88c7045..000000000000 --- a/packages/jobs/dist/index.js +++ /dev/null @@ -1,43 +0,0 @@ -"use strict"; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "default"), secondTarget && __copyProps(secondTarget, mod, "default")); -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); -var src_exports = {}; -__export(src_exports, { - BaseAdapter: () => import_BaseAdapter.BaseAdapter, - Executor: () => import_Executor.Executor, - JobManager: () => import_JobManager.JobManager, - PrismaAdapter: () => import_PrismaAdapter.PrismaAdapter, - Worker: () => import_Worker.Worker -}); -module.exports = __toCommonJS(src_exports); -__reExport(src_exports, require("./errors"), module.exports); -var import_JobManager = require("./core/JobManager"); -var import_Executor = require("./core/Executor"); -var import_Worker = require("./core/Worker"); -var import_BaseAdapter = require("./adapters/BaseAdapter/BaseAdapter"); -var import_PrismaAdapter = require("./adapters/PrismaAdapter/PrismaAdapter"); -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - BaseAdapter, - Executor, - JobManager, - PrismaAdapter, - Worker, - ...require("./errors") -}); diff --git a/packages/jobs/dist/loaders.d.ts b/packages/jobs/dist/loaders.d.ts deleted file mode 100644 index 661b8fef2927..000000000000 --- a/packages/jobs/dist/loaders.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { JobManager } from './core/JobManager'; -import type { Adapters, BasicLogger, Job, JobComputedProperties } from './types'; -/** - * Loads the job manager from the users project - * - * @returns JobManager - */ -export declare const loadJobsManager: () => Promise>; -/** - * Load a specific job implementation from the users project - */ -export declare const loadJob: ({ name: jobName, path: jobPath, }: JobComputedProperties) => Promise>; -//# sourceMappingURL=loaders.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/loaders.d.ts.map b/packages/jobs/dist/loaders.d.ts.map deleted file mode 100644 index 26161aba8fa7..000000000000 --- a/packages/jobs/dist/loaders.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"loaders.d.ts","sourceRoot":"","sources":["../src/loaders.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAEnD,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,GAAG,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAA;AAGhF;;;;GAIG;AACH,eAAO,MAAM,eAAe,QAAa,OAAO,CAC9C,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,WAAW,CAAC,CAgB5C,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,OAAO,sCAGjB,qBAAqB,KAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE,CAAC,CAgB1D,CAAA"} \ No newline at end of file diff --git a/packages/jobs/dist/loaders.js b/packages/jobs/dist/loaders.js deleted file mode 100644 index 7cb214edf7ae..000000000000 --- a/packages/jobs/dist/loaders.js +++ /dev/null @@ -1,71 +0,0 @@ -"use strict"; -var __create = Object.create; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __getProtoOf = Object.getPrototypeOf; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( - // If the importer is in node compatibility mode or this is not an ESM - // file that has been converted to a CommonJS file using a Babel- - // compatible transform (i.e. "__esModule" has not been set), then set - // "default" to the CommonJS "module.exports" for node compatibility. - isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, - mod -)); -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); -var loaders_exports = {}; -__export(loaders_exports, { - loadJob: () => loadJob, - loadJobsManager: () => loadJobsManager -}); -module.exports = __toCommonJS(loaders_exports); -var import_node_fs = __toESM(require("node:fs")); -var import_node_path = __toESM(require("node:path")); -var import_project_config = require("@redwoodjs/project-config"); -var import_errors = require("./errors"); -var import_util = require("./util"); -const loadJobsManager = async () => { - const jobsConfigPath = (0, import_project_config.getPaths)().api.distJobsConfig; - if (!jobsConfigPath) { - throw new import_errors.JobsLibNotFoundError(); - } - const importPath = (0, import_util.makeFilePath)(jobsConfigPath); - const { jobs } = await import(importPath); - if (!jobs) { - throw new import_errors.JobsLibNotFoundError(); - } - return jobs; -}; -const loadJob = async ({ - name: jobName, - path: jobPath -}) => { - const completeJobPath = import_node_path.default.join((0, import_project_config.getPaths)().api.distJobs, jobPath) + ".js"; - if (!import_node_fs.default.existsSync(completeJobPath)) { - throw new import_errors.JobNotFoundError(jobName); - } - const importPath = (0, import_util.makeFilePath)(completeJobPath); - const jobModule = await import(importPath); - if (!jobModule[jobName]) { - throw new import_errors.JobNotFoundError(jobName); - } - return jobModule[jobName]; -}; -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - loadJob, - loadJobsManager -}); diff --git a/packages/jobs/dist/types.d.ts b/packages/jobs/dist/types.d.ts deleted file mode 100644 index a4f95a7e7d5a..000000000000 --- a/packages/jobs/dist/types.d.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { BaseAdapter } from './adapters/BaseAdapter/BaseAdapter'; -export interface BasicLogger { - debug: (message?: any, ...optionalParams: any[]) => void; - info: (message?: any, ...optionalParams: any[]) => void; - warn: (message?: any, ...optionalParams: any[]) => void; - error: (message?: any, ...optionalParams: any[]) => void; -} -export interface BaseJob { - id: string | number; - name: string; - path: string; - args: unknown[]; - attempts: number; -} -export type PossibleBaseJob = BaseJob | undefined; -export type Adapters = Record; -export interface WorkerConfig { - /** - * The name of the adapter to use for this worker. This must be one of the keys - * in the `adapters` object when you created the `JobManager`. - */ - adapter: keyof TAdapters; - /** - * The queue or queues that this worker should work on. You can pass a single - * queue name, an array of queue names, or the string `'*'` to work on all - * queues. - */ - queue: '*' | TQueues[number] | TQueues[number][]; - /** - * The maximum number of retries to attempt for a job before giving up. - * - * @default 24 - */ - maxAttempts?: number; - /** - * The maximum amount of time in seconds that a job can run before another - * worker will attempt to retry it. - * - * @default 14,400 (4 hours) - */ - maxRuntime?: number; - /** - * Whether a job that exceeds its `maxAttempts` should be deleted from the - * queue. If `false`, the job will remain in the queue but will not be - * processed further. - * - * @default false - */ - deleteFailedJobs?: boolean; - /** - * The amount of time in seconds to wait between polling the queue for new - * jobs. Some adapters may not need this if they do not poll the queue and - * instead rely on a subscription model. - * - * @default 5 - */ - sleepDelay?: number; - /** - * The number of workers to spawn for this worker configuration. - * - * @default 1 - */ - count?: number; - /** - * The logger to use for this worker. If not provided, the logger from the - * `JobManager` will be used. - */ - logger?: BasicLogger; -} -export interface JobManagerConfig { - /** - * An object containing all of the adapters that this job manager will use. - * The keys should be the names of the adapters and the values should be the - * adapter instances. - */ - adapters: TAdapters; - /** - * The logger to use for this job manager. If not provided, the logger will - * default to the console. - */ - logger: TLogger; - /** - * An array of all of queue names that jobs can be scheduled on to. Workers can - * be configured to work on a selection of these queues. - */ - queues: TQueues; - /** - * An array of worker configurations that define how jobs should be processed. - */ - workers: WorkerConfig[]; -} -export interface CreateSchedulerConfig { - /** - * The name of the adapter to use for this scheduler. This must be one of the keys - * in the `adapters` object when you created the `JobManager`. - */ - adapter: keyof TAdapters; - /** - * The logger to use for this scheduler. If not provided, the logger from the - * `JobManager` will be used. - */ - logger?: BasicLogger; -} -export interface JobDefinition { - /** - * The name of the queue that this job should always be scheduled on. This defaults - * to the queue that the scheduler was created with, but can be overridden when - * scheduling a job. - */ - queue: TQueues[number]; - /** - * The priority of the job in the range of 0-100. The lower the number, the - * higher the priority. The default is 50. - * @default 50 - */ - priority?: PriorityValue; - /** - * The function to run when this job is executed. - * - * @param args The arguments that were passed when the job was scheduled. - */ - perform: (...args: TArgs) => Promise | void; -} -export type JobComputedProperties = { - /** - * The name of the job that was defined in the job file. - */ - name: string; - /** - * The path to the job file that this job was defined in. - */ - path: string; -}; -export type Job = JobDefinition & JobComputedProperties; -export type ScheduleJobOptions = { - /** - * The number of seconds to wait before scheduling this job. This is mutually - * exclusive with `waitUntil`. - */ - wait: number; - waitUntil?: never; -} | { - wait?: never; - /** - * The date and time to schedule this job for. This is mutually exclusive with - * `wait`. - */ - waitUntil: Date; -}; -type PriorityValue = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100; -export {}; -//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/types.d.ts.map b/packages/jobs/dist/types.d.ts.map deleted file mode 100644 index 190db9703a85..000000000000 --- a/packages/jobs/dist/types.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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,CAAC,GACD,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,EAAE,GACF,GAAG,CAAA"} \ No newline at end of file diff --git a/packages/jobs/dist/types.js b/packages/jobs/dist/types.js deleted file mode 100644 index 43ae53610554..000000000000 --- a/packages/jobs/dist/types.js +++ /dev/null @@ -1,16 +0,0 @@ -"use strict"; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); -var types_exports = {}; -module.exports = __toCommonJS(types_exports); diff --git a/packages/jobs/dist/util.d.ts b/packages/jobs/dist/util.d.ts deleted file mode 100644 index b10a4aeb18a6..000000000000 --- a/packages/jobs/dist/util.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export declare function makeFilePath(path: string): string; -//# sourceMappingURL=util.d.ts.map \ No newline at end of file diff --git a/packages/jobs/dist/util.d.ts.map b/packages/jobs/dist/util.d.ts.map deleted file mode 100644 index 13d0f150c6c6..000000000000 --- a/packages/jobs/dist/util.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAGA,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,UAExC"} \ No newline at end of file diff --git a/packages/jobs/dist/util.js b/packages/jobs/dist/util.js deleted file mode 100644 index 8ea5e44be1cd..000000000000 --- a/packages/jobs/dist/util.js +++ /dev/null @@ -1,31 +0,0 @@ -"use strict"; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); -var util_exports = {}; -__export(util_exports, { - makeFilePath: () => makeFilePath -}); -module.exports = __toCommonJS(util_exports); -var import_node_url = require("node:url"); -function makeFilePath(path) { - return (0, import_node_url.pathToFileURL)(path).href; -} -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - makeFilePath -}); diff --git a/yarn.lock b/yarn.lock index 8bea500f49c2..7cec1bae1f92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8331,7 +8331,10 @@ __metadata: resolution: "@redwoodjs/jobs@workspace:packages/jobs" dependencies: "@prisma/client": "npm:5.18.0" + "@redwoodjs/framework-tools": "workspace:*" "@redwoodjs/project-config": "workspace:*" + concurrently: "npm:8.2.2" + publint: "npm:0.2.10" tsx: "npm:4.17.0" typescript: "npm:5.5.4" vitest: "npm:2.0.5" From 36fd31ea8dc6d442b808fd8e921dd8412d7145b6 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Mon, 19 Aug 2024 19:00:26 +0100 Subject: [PATCH 229/258] add queue and update cli test snapshot --- .../__tests__/__snapshots__/job.test.ts.snap | 19 +++++++++---------- .../generate/job/__tests__/job.test.ts | 3 +++ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/generate/job/__tests__/__snapshots__/job.test.ts.snap b/packages/cli/src/commands/generate/job/__tests__/__snapshots__/job.test.ts.snap index 611dfa98f630..5df426fc7edb 100644 --- a/packages/cli/src/commands/generate/job/__tests__/__snapshots__/job.test.ts.snap +++ b/packages/cli/src/commands/generate/job/__tests__/__snapshots__/job.test.ts.snap @@ -15,24 +15,23 @@ export type StandardScenario = ScenarioData exports[`Single word default files > creates a single word function file > Test snapshot 1`] = ` "import { SampleJob } from './SampleJob' -describe('Sample', () => { +describe('SampleJob', () => { it('should not throw any errors', async () => { - const job = new SampleJob() - - await expect(job.perform()).resolves.not.toThrow() + await expect(SampleJob.perform()).resolves.not.toThrow() }) }) " `; exports[`Single word default files > creates a single word function file 1`] = ` -"import { RedwoodJob } from '@redwoodjs/jobs' +"import { jobs } from 'src/lib/jobs' -export class SampleJob extends RedwoodJob { - async perform() { - // job implementation here - } -} +export const SampleJob = jobs.createJob({ + queue: 'default', + perform: async () => { + jobs.logger.info('SampleJob is performing...') + }, +}) " `; diff --git a/packages/cli/src/commands/generate/job/__tests__/job.test.ts b/packages/cli/src/commands/generate/job/__tests__/job.test.ts index b2600ea886a3..fbec73c53610 100644 --- a/packages/cli/src/commands/generate/job/__tests__/job.test.ts +++ b/packages/cli/src/commands/generate/job/__tests__/job.test.ts @@ -14,6 +14,7 @@ type WordFilesType = { [key: string]: string } describe('Single word default files', async () => { const files: WordFilesType = await jobGenerator.files({ name: 'Sample', + queueName: 'default', tests: true, typescript: true, }) @@ -47,6 +48,7 @@ describe('multi-word files', () => { it('creates a multi word function file', async () => { const multiWordDefaultFiles = await jobGenerator.files({ name: 'send-mail', + queueName: 'default', tests: false, typescript: true, }) @@ -64,6 +66,7 @@ describe('multi-word files', () => { describe('generation of js files', async () => { const jsFiles: WordFilesType = await jobGenerator.files({ name: 'Sample', + queueName: 'default', tests: true, typescript: false, }) From 5c5ffd63fa2af1d927b3d87d00bc6fd314437824 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 19 Aug 2024 11:39:31 -0700 Subject: [PATCH 230/258] Start of jobs docs update --- docs/docs/background-jobs.md | 244 +++++++++++++------- docs/static/img/background-jobs/jobs-db.png | Bin 0 -> 92582 bytes 2 files changed, 159 insertions(+), 85 deletions(-) create mode 100644 docs/static/img/background-jobs/jobs-db.png diff --git a/docs/docs/background-jobs.md b/docs/docs/background-jobs.md index 888768365227..c12808bda238 100644 --- a/docs/docs/background-jobs.md +++ b/docs/docs/background-jobs.md @@ -1,6 +1,6 @@ # Background Jobs -No one likes waiting in line. This is especially true of your website: users don't want to wait for things to load that don't directly impact the task they're trying to accomplish. For example, sending a "welcome" email when a new user signs up. The process of sending the email could take as long or longer than the sum total of everything else that happens during that request. Why make the user wait for it? As long as they eventually get the email, everything is good. +No one likes waiting in line. This is especially true of your website: users don't want to wait for things to load that don't directly impact the task they're trying to accomplish. For example, sending a "welcome" email when a new user signs up. The process of sending the email could as long or longer than the sum total of everything else that happens during that request. Why make the user wait for it? As long as they eventually get the email, everything is good. A typical create-user flow could look something like this: @@ -14,25 +14,81 @@ The user's response is returned much quicker, and the email is sent by another p The job is completely self-contained and has everything it needs to perform its task. Let's see how Redwood implements this workflow. -## Overview & Quick Start +## Quick Start -Before we get into anything with jobs specifically, we want to make sure `NODE_ENV` is set properly in your dev environment as this tells the job workers where to pull your jobs from: either `api/src/jobs` (when in development) or `api/dist/jobs` (when in production-like environments). +Don't care about all the theory and just want to get stuff running in the background? -In your `.env` or `.env.defaults` file, add an entry for `NODE_ENV`: +**Setup** -```.env -NODE_ENV=development +Run the setup command to get the jobs configuration file created and migrate the database with a new `BackgroundJob` table: + +```bash +yarn rw setup jobs +yarn rw prisma migrate dev ``` -If `NODE_ENV` is _not_ set then the job runner will assume you're running a production-like environment and get jobs from `api/dist/jobs`. If you try to start a job worker and see this error: +This created `api/src/lib/jobs.js` with a sample config. You can leave this as is for now. +**Create a Job** + +```bash +yarn rw g job SampleJob ``` -JobsLibNotFoundError: api/src/lib/jobs.ts not found. Run `yarn rw setup jobs` to create this file and configure background jobs + +This created `api/src/jobs/SampleJob/SampleJob.js` and a test and scenario file. For now the job just outputs a message to the logs, but you'll fill out the `perform()` function to take any arguments you want and perform any work you want to do. Let's update the job to take a user's `id` and then just print that to the logs: + +```js +import { jobs } from 'src/lib/jobs' + +export const SampleJob = jobs.createJob({ + queue: 'default', + // highlight-start + perform: async (userId) => { + jobs.logger.info(`Received user id ${userId}`) + // highlight-end + }, +}) +``` + +**Schedule a Job** + +You'll most likely be scheduling work as the result of one of your service functions being executed. Let's say we want to schedule our `SampleJob` whenever a new user is created: + +```js title="api/src/services/users/users.js" +import { db } from 'src/lib/db' +// highlight-next-line +import { later } from 'src/lib/jobs' +import { SampleJob } from 'src/jobs/SampleJob' + +export const createUser = ({ input }) => { + const user = await db.user.create({ data: input }) + // highlight-next-line + await later(SampleJob, [user.id], { wait: 60 }) + return user +} +``` + +The second argument is an array of all the arguments your job should receive. The job itself defines them as normal, named arguments, but when you schedule you wrap them in an array. + +The third argument is an object with options, in this case the number of seconds to wait before this job will be run (60 seconds). + +If you check your database you'll see your job is now listed in the `BackgroundJob` table: + +![image](/img/background-jobs/jobs-db.png) + +**Executing Jobs** + +Start the worker process to find jobs in the DB and execute them: + +```bash +yarn rw jobs work ``` -Then `NODE_ENV` is probably not set! +This process will stay attached to the terminal and show you debug log output as it looks for jobs to run. Note that since we scheduled our job to wait 60 seconds before running, the runner will not find a job to work on right away (unless it's already been a minute since you scheduled it!). + +That's the basics of jobs! Keep reading to get the details, including how you to run your job runners in production. -### Workflow +## Overview There are three components to the background job system in Redwood: @@ -40,9 +96,15 @@ There are three components to the background job system in Redwood: 2. Storage 3. Execution -**Scheduling** is the main interface to background jobs from within your application code. This is where you tell the system to run a job at some point in the future, whether that's "as soon as possible" or to delay for an amount of time first, or to run at a specific datetime in the future. Scheduling is handled by calling `performLater()` on an instance of your job. +**Scheduling** is the main interface to background jobs from within your application code. This is where you tell the system to run a job at some point in the future, whether that's: + +* as soon as possible +* delay for an amount of time before running +* run at a specific datetime in the future + +Scheduling is handled by invoking the function returned from `createScheduler()`: by default this is a function named `later` that's exported from `api/src/lib/jobs.js`. -**Storage** is necessary so that your jobs are decoupled from your application. By default jobs are stored in your database. This allows you to scale everything independently: the api server (which is scheduling jobs), the database (which is storing the jobs ready to be run), and the job workers (which are executing the jobs). +**Storage** is necessary so that your jobs are decoupled from your application. With the included **PrismaAdapter** jobs are stored in your database. This allows you to scale everything independently: the api server (which is scheduling jobs), the database (which is storing the jobs ready to be run), and the job workers (which are executing the jobs). **Execution** is handled by a job worker, which takes a job from storage, executes it, and then does something with the result, whether it was a success or failure. @@ -63,24 +125,34 @@ yarn rw prisma migrate dev Let's look at the config file. Comments have been removed for brevity: ```js -import { PrismaAdapter, RedwoodJob } from '@redwoodjs/jobs' +import { PrismaAdapter, JobManager } from '@redwoodjs/jobs' import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' -export const adapter = new PrismaAdapter({ db, logger }) -export { logger } - -export const workerConfig: WorkerConfig = { - maxAttempts: 24, - maxRuntime: 14_400, - sleepDelay: 5, - deleteFailedJobs: false, -} - -RedwoodJob.config({ adapter, logger }) +export const jobs = new JobManager({ + adapters: { + prisma: new PrismaAdapter({ db, logger }), + }, + queues: ['default'] as const, + logger, + workers: [ + { + adapter: 'prisma', + logger, + queue: '*', + count: 1, + maxAttempts: 24, + maxRuntime: 14_400, + deleteFailedJobs: false, + sleepDelay: 5, + }, + ], +}) -export const jobs = {} +export const later = jobs.createScheduler({ + adapter: 'prisma', +}) ``` We'll go into more detail on this file later (see [RedwoodJob (Global) Configuration](#redwoodjob-global-configuration)), but what's there now is fine to get started creating a job. @@ -93,7 +165,9 @@ We have a generator that creates a job in `api/src/jobs`: yarn rw g job SendWelcomeEmail ``` -Jobs are defined as a subclass of the `RedwoodJob` class and at a minimum must contain the function named `perform()` which contains the logic for your job. You can add as many additional functions you want to support the task your job is performing, but `perform()` is what's invoked by the job worker that we'll see later. +Jobs are defined as a plain object and given to the `createJob()` function (which is called on the `jobs` export in the config file above). + +At a minimum, a job must contain the name of the `queue` the job should be saved to, and a function named `perform()` which contains the logic for your job. You can add as many additional properties to the you want to support the task your job is performing, but `perform()` is what's invoked by the job worker that we'll see later. An example `SendWelcomeEmailJob` may look something like: @@ -179,7 +253,7 @@ await jobs.sendWelcomeEmail.set({ wait: 300 }).performLater(user.id) :::info Job Run Time Guarantees -Job is never _guaranteed_ to run at an exact time. The worker could be busy working on other jobs and can't get to yours just yet. The time you set for your job to run is the _soonest_ it could possibly run. +Job is never *guaranteed* to run at an exact time. The worker could be busy working on other jobs and can't get to yours just yet. The time you set for your job to run is the *soonest* it could possibly run. If you absolutely, positively need your job to run right now, take a look at the `performNow()` function instead of `performLater()`. The response from the server will wait until the job is complete, but you'll know for sure that it has run. @@ -232,14 +306,14 @@ If that quick start covered your use case, great, you're done for now! Take a lo The rest of this doc describes more advanced usage, like: -- Assigning jobs to named **queues** -- Setting a **priority** so that some jobs always run before others -- Using different adapters and loggers on a per-job basis -- Starting more than one worker -- Having some workers focus on only certain queues -- Configuring individual workers to use different adapters -- Manually workers without the job runner monitoring them -- And more! +* Assigning jobs to named **queues** +* Setting a **priority** so that some jobs always run before others +* Using different adapters and loggers on a per-job basis +* Starting more than one worker +* Having some workers focus on only certain queues +* Configuring individual workers to use different adapters +* Manually workers without the job runner monitoring them +* And more! ## RedwoodJob (Global) Configuration @@ -277,8 +351,8 @@ Jobs will inherit a default queue name of `"default"` and a priority of `50`. Config can be given the following options: -- `adapter`: **[required]** The adapter to use for scheduling your job. -- `logger`: Made available as `this.logger` within your job for logging whatever you'd like during the `perform()` step. This defaults to `console`. +* `adapter`: **[required]** The adapter to use for scheduling your job. +* `logger`: Made available as `this.logger` within your job for logging whatever you'd like during the `perform()` step. This defaults to `console`. ### Exporting `jobs` @@ -308,7 +382,7 @@ export const updateProduct = async ({ id, input }) => { } ``` -It _is_ possible to skip this export altogther and import and schedule individual jobs manually: +It *is* possible to skip this export altogther and import and schedule individual jobs manually: ```js // api/src/services/products/products.js @@ -327,8 +401,8 @@ HOWEVER, this will lead to unexpected behavior if you're not aware of the follow If you don't export a `jobs` object and then `import` it when you want to schedule a job, the `Redwood.config()` line will never be executed and your jobs will not receive a default configuration! This means you'll need to either: -- Invoke `RedwoodJob.config()` somewhere before scheduling your job -- Manually set the adapter/logger/etc. in each of your jobs. +* Invoke `RedwoodJob.config()` somewhere before scheduling your job +* Manually set the adapter/logger/etc. in each of your jobs. We'll see examples of configuring the individual jobs with an adapter and logger below. @@ -338,10 +412,10 @@ We'll see examples of configuring the individual jobs with an adapter and logger All jobs have some default configuration set for you if don't do anything different: -- `queue` : jobs can be in named queues and have dedicated workers that only pull jobs from that queue. This lets you scale not only your entire job runner independently of the rest of your app, but scale the individual queues as well. By default, all jobs will go in a queue named "default" if you don't override it. -- `priority` : within a single queue you can jobs that are more or less important. The workers will pull jobs off the queue with a higher priority before working on ones with a lower priority. The default priority is `50`. A lower number is _higher_ in priority than a lower number. ie. the workers will work on a job with a priority of `1` before they work on one with a priority of `100`. -- `logger` : jobs will log to the console if you don't tell them otherwise. The logger exported from `api/src/lib/logger.js` works well the job runner, so we recommend using that by setting it in `RedwoodJob.config()` or on a per-job basis. -- `adapter` : the adapter to use to store this jobs. There is no default adapter set for jobs, so you'll need to set this in `RedwoodJob.config()` or on a per-job basis. +* `queue` : jobs can be in named queues and have dedicated workers that only pull jobs from that queue. This lets you scale not only your entire job runner independently of the rest of your app, but scale the individual queues as well. By default, all jobs will go in a queue named "default" if you don't override it. +* `priority` : within a single queue you can jobs that are more or less important. The workers will pull jobs off the queue with a higher priority before working on ones with a lower priority. The default priority is `50`. A lower number is *higher* in priority than a lower number. ie. the workers will work on a job with a priority of `1` before they work on one with a priority of `100`. +* `logger` : jobs will log to the console if you don't tell them otherwise. The logger exported from `api/src/lib/logger.js` works well the job runner, so we recommend using that by setting it in `RedwoodJob.config()` or on a per-job basis. +* `adapter` : the adapter to use to store this jobs. There is no default adapter set for jobs, so you'll need to set this in `RedwoodJob.config()` or on a per-job basis. If you don't do anything special, a job will inherit the adapter and logger you set with the call to `RedwoodJob.config()`. However, you can override these settings on a per-job basis. You don't have to set all of them, you can use them in any combination you want: @@ -380,9 +454,9 @@ const adapter = new PrismaAdapter({ }) ``` -- `db`: **[required]** an instance of `PrismaClient` that the adapter will use to store, find and update the status of jobs. In most cases this will be the `db` variable exported from `api/src/lib/db.js`. This must be set in order for the adapter to be initialized! -- `model`: the name of the model that was created to store jobs. This defaults to `BackgroundJob` -- `logger`: events that occur within the adapter will be logged using this. This defaults to `console` but the `logger` exported from `api/src/lib/logger` works great, too. +* `db`: **[required]** an instance of `PrismaClient` that the adapter will use to store, find and update the status of jobs. In most cases this will be the `db` variable exported from `api/src/lib/db.js`. This must be set in order for the adapter to be initialized! +* `model`: the name of the model that was created to store jobs. This defaults to `BackgroundJob` +* `logger`: events that occur within the adapter will be logged using this. This defaults to `console` but the `logger` exported from `api/src/lib/logger` works great, too. ## Job Scheduling @@ -399,13 +473,13 @@ const job = new SendWelcomeEmailJob() job.set({ wait: 300 }).performLater() ``` -You can also set options when you create the instance. For example, if _every_ invocation of this job should wait 5 minutes, no need to `set()` that each time, just initialize it with that option: +You can also set options when you create the instance. For example, if *every* invocation of this job should wait 5 minutes, no need to `set()` that each time, just initialize it with that option: ```js // api/src/lib/jobs.js export const jobs = { // highlight-next-line - sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }), + sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }) } // api/src/services/users/users.js @@ -423,7 +497,7 @@ export const createUser = async ({ input }) => { // api/src/lib/jobs.js export const jobs = { // highlight-next-line - sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }), // 5 minutes + sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }) // 5 minutes } // api/src/services/users/users.js @@ -449,18 +523,18 @@ Once you have your instance you can inspect the options set on it: ```js const job = new SendWelcomeEmail() // set by RedwoodJob.config or static properies -job.adapter // => PrismaAdapter instance -jog.logger // => logger instance +job.adapter // => PrismaAdapter instance +jog.logger // => logger instance // set via `set()` or provided during job instantiaion -job.queue // => 'default' -job.priority // => 50 -job.wait // => 300 +job.queue // => 'default' +job.priority // => 50 +job.wait // => 300 job.waitUntil // => null // computed internally -job.runAt // => 2025-07-27 12:35:00 UTC -// ^ the actual computed Date of now + `wait` +job.runAt // => 2025-07-27 12:35:00 UTC + // ^ the actual computed Date of now + `wait` ``` :::info @@ -478,9 +552,9 @@ import { AnnualReportGenerationJob } from 'api/src/jobs/AnnualReportGenerationJo AnnualReportGenerationJob.performLater() // or -AnnualReportGenerationJob.set({ - waitUntil: new Date(2025, 0, 1), -}).performLater() +AnnualReportGenerationJob + .set({ waitUntil: new Date(2025, 0, 1) }) + .performLater() ``` Using this syntax comes with a caveat: since no `RedwoodJob.config()` was called you would need to configure the `adapter` and `logger` directly on `AnnualReportGenerationJob` via static properties (unless you were sure that `RedwoodJob.config()` was called somewhere before this code executes). See the note at the end of the [Exporting jobs](#exporting-jobs) section explaining this limitation. @@ -489,10 +563,10 @@ Using this syntax comes with a caveat: since no `RedwoodJob.config()` was called You can pass several options in a `set()` call on your instance or class: -- `wait`: number of seconds to wait before the job will run -- `waitUntil`: a specific `Date` in the future to run at -- `queue`: the named queue to put this job in (overrides any `static queue` set on the job itself) -- `priority`: the priority to give this job (overrides any `static priority` set on the job itself) +* `wait`: number of seconds to wait before the job will run +* `waitUntil`: a specific `Date` in the future to run at +* `queue`: the named queue to put this job in (overrides any `static queue` set on the job itself) +* `priority`: the priority to give this job (overrides any `static priority` set on the job itself) ## Job Runner @@ -512,7 +586,7 @@ This process will stay attached the console and continually look for new jobs an :::caution Long running jobs -It's currently up to you to make sure your job completes before your `maxRuntime` limit is reached! NodeJS Promises are not truly cancelable: you can reject early, but any Promises that were started _inside_ will continue running unless they are also early rejected, recursively forever. +It's currently up to you to make sure your job completes before your `maxRuntime` limit is reached! NodeJS Promises are not truly cancelable: you can reject early, but any Promises that were started *inside* will continue running unless they are also early rejected, recursively forever. The only way to guarantee a job will completely stop no matter what is for your job to spawn an actual OS level process with a timeout that kills it after a certain amount of time. We may add this functionality natively to Jobs in the near future: let us know if you'd benefit from this being built in! @@ -636,16 +710,16 @@ The job runner sets the `--maxAttempts`, `--maxRuntime` and `--sleepDelay` flags ### Flags -- `--id` : a number identifier that's set as part of the process name. For example starting a worker with \* `--id=0` and then inspecting your process list will show one running named `rw-job-worker.0` -- `--queue` : the named queue for the worker to focus on. Leaving this flag off defaults to watching all queues. This will also effect the process title name. Setting `--queue=email` would lead to process named \* `rw-job-worker.email.0` (assuming `--id=0`) -- `--workoff` : if this flag is present then the worker will terminate itself after the queue it is watching is empty -- `--clear` : if this flag is present the worker will call the `clear()` function on the adapter (which should remove all jobs in storage) and then exit -- `--maxAttempts` : The max number of times to retry a job before considering it failed, and will not be retried. Defaults to `24`. -- `--maxRuntime` : The max amount of time, in seconds, a job should run before being eligible to be picked up by another worker instead, effectively starting over. Defaults to `14400` which is 4 hours. -- `--sleepDelay` : How long to wait, in seconds, before querying the adapter for another available job to run. Careful not to DDoS your job storage system by setting this too low! -- `--deleteFailedJobs` : If `maxAttempts` is reached, what to do with the job. Defaults to `false` meaning the job will remain in the queue, but the workers will not pick it up to work on again. Useful for debugging why a job may have failed. -- `--adapter` : The name of the variable exported from `api/src/lib/jobs.js` to use as the adapter for this worker instance. Defalts to `adapter`. The worker will error on startup if this variable is not exported by your jobs config file. -- `--logger` : The name of the variable exported from `api/src/lib/jobs.js` to us as the logger for this worker instance. Defaults to `logger` but will fall back to `console` if no `logger` is exported. +* `--id` : a number identifier that's set as part of the process name. For example starting a worker with * `--id=0` and then inspecting your process list will show one running named `rw-job-worker.0` +* `--queue` : the named queue for the worker to focus on. Leaving this flag off defaults to watching all queues. This will also effect the process title name. Setting `--queue=email` would lead to process named * `rw-job-worker.email.0` (assuming `--id=0`) +* `--workoff` : if this flag is present then the worker will terminate itself after the queue it is watching is empty +* `--clear` : if this flag is present the worker will call the `clear()` function on the adapter (which should remove all jobs in storage) and then exit +* `--maxAttempts` : The max number of times to retry a job before considering it failed, and will not be retried. Defaults to `24`. +* `--maxRuntime` : The max amount of time, in seconds, a job should run before being eligible to be picked up by another worker instead, effectively starting over. Defaults to `14400` which is 4 hours. +* `--sleepDelay` : How long to wait, in seconds, before querying the adapter for another available job to run. Careful not to DDoS your job storage system by setting this too low! +* `--deleteFailedJobs` : If `maxAttempts` is reached, what to do with the job. Defaults to `false` meaning the job will remain in the queue, but the workers will not pick it up to work on again. Useful for debugging why a job may have failed. +* `--adapter` : The name of the variable exported from `api/src/lib/jobs.js` to use as the adapter for this worker instance. Defalts to `adapter`. The worker will error on startup if this variable is not exported by your jobs config file. +* `--logger` : The name of the variable exported from `api/src/lib/jobs.js` to us as the logger for this worker instance. Defaults to `logger` but will fall back to `console` if no `logger` is exported. ## Creating Your Own Adapter @@ -653,18 +727,18 @@ We'd love the community to contribue adapters for Redwood Job! Take a look at th The general gist of the required functions: -- `find()` should find a job to be run, lock it and return it (minimum return of and object containing `handler` and `args` properties) -- `schedule()` accepts `handler`, `args`, `runAt`, `queue` and `priority` and should store the job -- `success()` accepts the same job object returned from `find()` and does whatever success means (delete the job from the queue, most likely) -- `failure()` accepts the same job object returned from `find()` and does whatever failure means (unlock the job and reschedule a time for it to run again in the future, or give up if `maxAttempts` is reached) -- `clear()` remove all jobs from the queue (mostly used in development) +* `find()` should find a job to be run, lock it and return it (minimum return of and object containing `handler` and `args` properties) +* `schedule()` accepts `handler`, `args`, `runAt`, `queue` and `priority` and should store the job +* `success()` accepts the same job object returned from `find()` and does whatever success means (delete the job from the queue, most likely) +* `failure()` accepts the same job object returned from `find()` and does whatever failure means (unlock the job and reschedule a time for it to run again in the future, or give up if `maxAttempts` is reached) +* `clear()` remove all jobs from the queue (mostly used in development) ## The Future There's still more to add to background jobs! Our current TODO list: -- More adapters: Redis, SQS, RabbitMQ... -- RW Studio integration: monitor the state of your outstanding jobs -- Baremetal integration: if jobs are enabled, monitor the workers with pm2 -- Recurring jobs -- Livecycle hooks: `beforePerform()`, `afterPerform()`, `afterSuccess()`, `afterFailure()` +* More adapters: Redis, SQS, RabbitMQ... +* RW Studio integration: monitor the state of your outstanding jobs +* Baremetal integration: if jobs are enabled, monitor the workers with pm2 +* Recurring jobs +* Livecycle hooks: `beforePerform()`, `afterPerform()`, `afterSuccess()`, `afterFailure()` diff --git a/docs/static/img/background-jobs/jobs-db.png b/docs/static/img/background-jobs/jobs-db.png new file mode 100644 index 0000000000000000000000000000000000000000..1fd900de565f94d1c10680273a3b9718fd3e6662 GIT binary patch literal 92582 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 literal 0 HcmV?d00001 From 7def462e60b9c0e109b37dc71a858263b5908943 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 19 Aug 2024 12:19:20 -0700 Subject: [PATCH 231/258] More jobs docs --- docs/docs/background-jobs.md | 298 ++++++++++++++++++----------------- 1 file changed, 154 insertions(+), 144 deletions(-) diff --git a/docs/docs/background-jobs.md b/docs/docs/background-jobs.md index c12808bda238..b47be8031f21 100644 --- a/docs/docs/background-jobs.md +++ b/docs/docs/background-jobs.md @@ -2,6 +2,8 @@ No one likes waiting in line. This is especially true of your website: users don't want to wait for things to load that don't directly impact the task they're trying to accomplish. For example, sending a "welcome" email when a new user signs up. The process of sending the email could as long or longer than the sum total of everything else that happens during that request. Why make the user wait for it? As long as they eventually get the email, everything is good. +## Concepts + A typical create-user flow could look something like this: ![image](/img/background-jobs/jobs-before.png) @@ -10,15 +12,35 @@ If we want the email to be send asynchonously, we can shuttle that process off i ![image](/img/background-jobs/jobs-after.png) -The user's response is returned much quicker, and the email is sent by another process which is connected to a user's session. All of the logic around sending the email is packaged up as a **job** and a **job worker** is responsible for executing it. +The user's response is returned much quicker, and the email is sent by another process, literally running in the background. All of the logic around sending the email is packaged up as a **job** and a **job worker** is responsible for executing it. + +The job is completely self-contained and has everything it needs to perform its task. There are three components to the background job system in Redwood: + +1. Scheduling +2. Storage +3. Execution -The job is completely self-contained and has everything it needs to perform its task. Let's see how Redwood implements this workflow. +**Scheduling** is the main interface to background jobs from within your application code. This is where you tell the system to run a job at some point in the future, whether that's: + +- as soon as possible +- delay for an amount of time before running +- run at a specific datetime in the future + +**Storage** is necessary so that your jobs are decoupled from your running application. With the included `PrismaAdapter` jobs are stored in your database. This allows you to scale everything independently: the api server (which is scheduling jobs), the database (which is storing the jobs ready to be run), and the job workers (which are executing the jobs). + +**Execution** is handled by a job worker, which takes a job from storage, executes it, and then does something with the result, whether it was a success or failure. + +:::info Job execution time is never guaranteed + +When scheduling a job, you're really saying "this is the earliest possible time I want this job to run": based on what other jobs are in the queue, and how busy the workers are, they may not get a chance to execute this one particiular job for an indeterminate amount of time. The only thing that's guaranteed is that it won't run any _earlier_ than the time you specify. + +::: ## Quick Start -Don't care about all the theory and just want to get stuff running in the background? +We'll get into more detail later, but if you just to get up and running quickly, here we go. -**Setup** +### Setup Run the setup command to get the jobs configuration file created and migrate the database with a new `BackgroundJob` table: @@ -27,9 +49,9 @@ yarn rw setup jobs yarn rw prisma migrate dev ``` -This created `api/src/lib/jobs.js` with a sample config. You can leave this as is for now. +This created `api/src/lib/jobs.js` (or `.ts`) with a sample config. You can leave this as is for now. -**Create a Job** +### Create a Job ```bash yarn rw g job SampleJob @@ -45,12 +67,12 @@ export const SampleJob = jobs.createJob({ // highlight-start perform: async (userId) => { jobs.logger.info(`Received user id ${userId}`) - // highlight-end + // highlight-end }, }) ``` -**Schedule a Job** +### Schedule a Job You'll most likely be scheduling work as the result of one of your service functions being executed. Let's say we want to schedule our `SampleJob` whenever a new user is created: @@ -68,15 +90,15 @@ export const createUser = ({ input }) => { } ``` -The second argument is an array of all the arguments your job should receive. The job itself defines them as normal, named arguments, but when you schedule you wrap them in an array. +The second argument is an array of all the arguments your job should receive. The job itself defines them as normal, named arguments (like `userId`), but when you schedule you wrap them in an array (like `[user.id]`). -The third argument is an object with options, in this case the number of seconds to wait before this job will be run (60 seconds). +The third argument is an optional object that defines a couple of options. In this case, the number of seconds to `wait` before this job will be run (60 seconds). If you check your database you'll see your job is now listed in the `BackgroundJob` table: ![image](/img/background-jobs/jobs-db.png) -**Executing Jobs** +### Executing a Job Start the worker process to find jobs in the DB and execute them: @@ -88,25 +110,9 @@ This process will stay attached to the terminal and show you debug log output as That's the basics of jobs! Keep reading to get the details, including how you to run your job runners in production. -## Overview +## In-Depth Start -There are three components to the background job system in Redwood: - -1. Scheduling -2. Storage -3. Execution - -**Scheduling** is the main interface to background jobs from within your application code. This is where you tell the system to run a job at some point in the future, whether that's: - -* as soon as possible -* delay for an amount of time before running -* run at a specific datetime in the future - -Scheduling is handled by invoking the function returned from `createScheduler()`: by default this is a function named `later` that's exported from `api/src/lib/jobs.js`. - -**Storage** is necessary so that your jobs are decoupled from your application. With the included **PrismaAdapter** jobs are stored in your database. This allows you to scale everything independently: the api server (which is scheduling jobs), the database (which is storing the jobs ready to be run), and the job workers (which are executing the jobs). - -**Execution** is handled by a job worker, which takes a job from storage, executes it, and then does something with the result, whether it was a success or failure. +Let's go into more depth in each of the parts of the job system. ### Installation @@ -155,9 +161,11 @@ export const later = jobs.createScheduler({ }) ``` -We'll go into more detail on this file later (see [RedwoodJob (Global) Configuration](#redwoodjob-global-configuration)), but what's there now is fine to get started creating a job. +Two variables are exported: one is an instance of the `JobManager` called `jobs` on which you'll call functions to create jobs. The other is `later` which is an instance of the `Scheduler`, which is responsible for getting your job into the storage system (out of the box this will be the database thanks to the `PrismaAdapter`). + +We'll go into more detail on this file later (see [Job Configuration](#job-configuration)), but what's there now is fine to get started creating a job. -### Creating a Job +### Creating New Jobs We have a generator that creates a job in `api/src/jobs`: @@ -167,80 +175,56 @@ yarn rw g job SendWelcomeEmail Jobs are defined as a plain object and given to the `createJob()` function (which is called on the `jobs` export in the config file above). -At a minimum, a job must contain the name of the `queue` the job should be saved to, and a function named `perform()` which contains the logic for your job. You can add as many additional properties to the you want to support the task your job is performing, but `perform()` is what's invoked by the job worker that we'll see later. +At a minimum, a job must contain the name of the `queue` the job should be saved to, and a function named `perform()` which contains the logic for your job. You can add additional properties to the object to support the task your job is performing, but `perform()` is what's invoked by the job worker that we'll see later. An example `SendWelcomeEmailJob` may look something like: ```js -import { RedwoodJob } from '@redwoodjs/jobs' -import { mailer } from 'src/lib/mailer' -import { WelcomeEmail } from 'src/mail/WelcomeEmail' - -export class SendWelcomeEmailJob extends RedwoodJob { +import { db } from 'src/lib/db' +import { jobs } from 'src/lib/jobs' - perform(userId) { +export const SendWelcomeEmailJob = jobs.createJob({ + queue: 'default', + perform: async (userId) => { const user = await db.user.findUnique({ where: { id: userId } }) mailer.send(WelcomeEmail({ user }), { to: user.email, subject: `Welcome to the site!`, }) - } - -} + }, +}) ``` -Note that `perform()` can take any arguments you want, but it's a best practice to keep them as simple as possible: a reference to this job and its arguments are stored in the database, so the list of arguments must be serializable to and from a string of JSON. +Note that `perform()` can take any arguments you want, but it's a best practice to keep them as simple as possible. With the `PrismaAdapter` the arguments are stored in the database, so the list of arguments must be serializable to and from a string of JSON. :::info Keeping Arguments Simple Most jobs will probably act against data in your database, so it makes sense to have the arguments simply be the `id` of those database records. When the job executes it will look up the full database record and then proceed from there. -If it's likely that the data in the database will change before your job is actually run, you may want to include the original values as arguments to be sure your job is being performed with the correct data. +If it's likely that the data in the database will change before your job is actually run, but you need the job to run with the original data, you may want to include the original values as arguments to your job. This way the job is sure to be working with those original values and not the potentially changed ones in the database. ::: -In addition to creating the shell of the job itself, the job generator will add an instance of your job to the `jobs` exported in the jobs config file: - -```js -import { PrismaAdapter, RedwoodJob } from '@redwoodjs/jobs' - -import { db } from 'src/lib/db' -import { logger } from 'src/lib/logger' -// highlight-next-line -import { SendWelcomeEmailJob } from 'src/jobs/SendWelcomeEmailJob' +### Scheduling Jobs -export const adapter = new PrismaAdapter({ db, logger }) -export { logger } +Remember the `later` export in the jobs config file: -export const workerConfig: WorkerConfig = { - maxAttempts: 24, - maxRuntime: 14_400, - sleepDelay: 5, - deleteFailedJobs: false, -} - -RedwoodJob.config({ adapter, logger }) - -export jobs = { - // highlight-next-line - sendWelcomeEmail: new SendWelcomeEmailJob() -} +```js +export const later = jobs.createScheduler({ + adapter: 'prisma', +}) ``` -This makes it easy to import and schedule your job as we'll see next. - -### Scheduling a Job - -All jobs expose a `performLater()` function (inherited from the parent `RedwoodJob` class). Simply call this function when you want to schedule your job. Carrying on with our example from above, let's schedule this job as part of the `createUser()` service that used to be sending the welcome email directly: +You call this function, passing the job, job arguments, and an optional options object when you want to schedule a job. Let's see how we'd schedule our welcome email to go out when a new user is created: ```js // highlight-next-line -import { jobs } from 'api/src/lib/jobs' +import { SendWelcomeEmailJob } from 'src/jobs/SendWelcomeEmailJob' export const createUser = async ({ input }) { const user = await db.user.create({ data: input }) // highlight-next-line - await jobs.sendWelcomeEmail.performLater(user.id) + await later(SendWelcomeEmailJob, [user.id]) return user } ``` @@ -248,24 +232,36 @@ export const createUser = async ({ input }) { By default the job will run as soon as possible. If you wanted to wait five minutes before sending the email you can set a `wait` time to a number of seconds: ```js -await jobs.sendWelcomeEmail.set({ wait: 300 }).performLater(user.id) +later(SendWelcomeEmailJob, [user.id], { wait: 300 }) ``` -:::info Job Run Time Guarantees +Or run it at a specific datetime: -Job is never *guaranteed* to run at an exact time. The worker could be busy working on other jobs and can't get to yours just yet. The time you set for your job to run is the *soonest* it could possibly run. +```js +later(MilleniumAnnouncementJob, [user.id], { + waitUntil: new Date(3000, 0, 1, 0, 0, 0), +}) +``` + +:::info Running a Job Immediately + +As noted in the [Concepts](#concepts) section, a job is never _guaranteed_ to run at an exact time. The worker could be busy working on other jobs and can't get to yours just yet. + +If you absolutely, positively need your job to run right _now_ you can call your job's `perform` function directly in your code: -If you absolutely, positively need your job to run right now, take a look at the `performNow()` function instead of `performLater()`. The response from the server will wait until the job is complete, but you'll know for sure that it has run. +```js +await SampleEmailJob.perform(user.id) +``` ::: If we were to query the `BackgroundJob` table after the job has been scheduled you'd see a new row: -```json +```js { - id: 1, + id: 132, attempts: 0, - handler: '{"handler":"SendWelcomeEmailJob","args":[335]}', + handler: '{"name":"SendWelcomeEmailJob",path:"SendWelcomeEmailJob/SendWelcomeEmailJob","args":[335]}', queue: 'default', priority: 50, runAt: 2024-07-12T22:27:51.085Z, @@ -278,7 +274,7 @@ If we were to query the `BackgroundJob` table after the job has been scheduled y } ``` -The `handler` field contains the name of the job class and the arguments its `perform()` function will receive. +The `handler` field contains the name of the job, file path to find it, and the arguments its `perform()` function will receive. :::info @@ -298,24 +294,38 @@ The runner is a sort of overseer that doesn't do any work itself, but spawns wor ![image](/img/background-jobs/jobs-terminal.png) -It checks the `BackgroundJob` table every few seconds for a new job and, if it finds one, locks it so that no other workers can have it, then calls your custom `perform()` function, passing it the arguments you gave to `performLater()` when you scheduled it. +It checks the `BackgroundJob` table every few seconds for a new job and, if it finds one, locks it so that no other workers can have it, then calls your `perform()` function, passing it the arguments you gave when you scheduled it. + +If the job succeeds then by default it's removed the database (using the `PrismaAdapter`, other adapters behavior may vary). If the job fails, the job is un-locked in the database, the `runAt` is set to an incremental backoff time in the future, and `lastError` is updated with the error that occurred. The job will now be picked up in the future once the `runAt` time has passed and it'll try again. + +There are a couple of additional modes that `rw jobs` can run in: -If the job succeeds then it's removed the database (using the `PrismaAdapter`, other adapters behavior may vary). If the job fails, the job is un-locked in the database, the `runAt` is set to an incremental backoff time in the future, and `lastError` is updated with the error that occurred. The job will now be picked up in the future once the `runAt` time has passed and it'll try again. +```bash +yarn rw jobs workoff +``` + +This mode will execute all jobs that eligible to run, then stop itself. + +```bash +yarn rw jobs start +``` -If that quick start covered your use case, great, you're done for now! Take a look at the [Deployment](#deployment) section when you're ready to go to production. +Starts the workers and then detaches them to run forever. Use `yarn rw jobs stop` to stop them, or `yarn rw jobs restart` to pick up any code changes to your jobs. + +### Everything Else The rest of this doc describes more advanced usage, like: -* Assigning jobs to named **queues** -* Setting a **priority** so that some jobs always run before others -* Using different adapters and loggers on a per-job basis -* Starting more than one worker -* Having some workers focus on only certain queues -* Configuring individual workers to use different adapters -* Manually workers without the job runner monitoring them -* And more! +- Assigning jobs to named **queues** +- Setting a **priority** so that some jobs always run before others +- Using different adapters and loggers on a per-job basis +- Starting more than one worker +- Having some workers focus on only certain queues +- Configuring individual workers to use different adapters +- Manually workers without the job runner monitoring them +- And more! -## RedwoodJob (Global) Configuration +## Job Configuration Let's take a closer look at `api/src/lib/jobs.js`: @@ -351,8 +361,8 @@ Jobs will inherit a default queue name of `"default"` and a priority of `50`. Config can be given the following options: -* `adapter`: **[required]** The adapter to use for scheduling your job. -* `logger`: Made available as `this.logger` within your job for logging whatever you'd like during the `perform()` step. This defaults to `console`. +- `adapter`: **[required]** The adapter to use for scheduling your job. +- `logger`: Made available as `this.logger` within your job for logging whatever you'd like during the `perform()` step. This defaults to `console`. ### Exporting `jobs` @@ -382,7 +392,7 @@ export const updateProduct = async ({ id, input }) => { } ``` -It *is* possible to skip this export altogther and import and schedule individual jobs manually: +It _is_ possible to skip this export altogther and import and schedule individual jobs manually: ```js // api/src/services/products/products.js @@ -401,8 +411,8 @@ HOWEVER, this will lead to unexpected behavior if you're not aware of the follow If you don't export a `jobs` object and then `import` it when you want to schedule a job, the `Redwood.config()` line will never be executed and your jobs will not receive a default configuration! This means you'll need to either: -* Invoke `RedwoodJob.config()` somewhere before scheduling your job -* Manually set the adapter/logger/etc. in each of your jobs. +- Invoke `RedwoodJob.config()` somewhere before scheduling your job +- Manually set the adapter/logger/etc. in each of your jobs. We'll see examples of configuring the individual jobs with an adapter and logger below. @@ -412,10 +422,10 @@ We'll see examples of configuring the individual jobs with an adapter and logger All jobs have some default configuration set for you if don't do anything different: -* `queue` : jobs can be in named queues and have dedicated workers that only pull jobs from that queue. This lets you scale not only your entire job runner independently of the rest of your app, but scale the individual queues as well. By default, all jobs will go in a queue named "default" if you don't override it. -* `priority` : within a single queue you can jobs that are more or less important. The workers will pull jobs off the queue with a higher priority before working on ones with a lower priority. The default priority is `50`. A lower number is *higher* in priority than a lower number. ie. the workers will work on a job with a priority of `1` before they work on one with a priority of `100`. -* `logger` : jobs will log to the console if you don't tell them otherwise. The logger exported from `api/src/lib/logger.js` works well the job runner, so we recommend using that by setting it in `RedwoodJob.config()` or on a per-job basis. -* `adapter` : the adapter to use to store this jobs. There is no default adapter set for jobs, so you'll need to set this in `RedwoodJob.config()` or on a per-job basis. +- `queue` : jobs can be in named queues and have dedicated workers that only pull jobs from that queue. This lets you scale not only your entire job runner independently of the rest of your app, but scale the individual queues as well. By default, all jobs will go in a queue named "default" if you don't override it. +- `priority` : within a single queue you can jobs that are more or less important. The workers will pull jobs off the queue with a higher priority before working on ones with a lower priority. The default priority is `50`. A lower number is _higher_ in priority than a lower number. ie. the workers will work on a job with a priority of `1` before they work on one with a priority of `100`. +- `logger` : jobs will log to the console if you don't tell them otherwise. The logger exported from `api/src/lib/logger.js` works well the job runner, so we recommend using that by setting it in `RedwoodJob.config()` or on a per-job basis. +- `adapter` : the adapter to use to store this jobs. There is no default adapter set for jobs, so you'll need to set this in `RedwoodJob.config()` or on a per-job basis. If you don't do anything special, a job will inherit the adapter and logger you set with the call to `RedwoodJob.config()`. However, you can override these settings on a per-job basis. You don't have to set all of them, you can use them in any combination you want: @@ -454,9 +464,9 @@ const adapter = new PrismaAdapter({ }) ``` -* `db`: **[required]** an instance of `PrismaClient` that the adapter will use to store, find and update the status of jobs. In most cases this will be the `db` variable exported from `api/src/lib/db.js`. This must be set in order for the adapter to be initialized! -* `model`: the name of the model that was created to store jobs. This defaults to `BackgroundJob` -* `logger`: events that occur within the adapter will be logged using this. This defaults to `console` but the `logger` exported from `api/src/lib/logger` works great, too. +- `db`: **[required]** an instance of `PrismaClient` that the adapter will use to store, find and update the status of jobs. In most cases this will be the `db` variable exported from `api/src/lib/db.js`. This must be set in order for the adapter to be initialized! +- `model`: the name of the model that was created to store jobs. This defaults to `BackgroundJob` +- `logger`: events that occur within the adapter will be logged using this. This defaults to `console` but the `logger` exported from `api/src/lib/logger` works great, too. ## Job Scheduling @@ -473,13 +483,13 @@ const job = new SendWelcomeEmailJob() job.set({ wait: 300 }).performLater() ``` -You can also set options when you create the instance. For example, if *every* invocation of this job should wait 5 minutes, no need to `set()` that each time, just initialize it with that option: +You can also set options when you create the instance. For example, if _every_ invocation of this job should wait 5 minutes, no need to `set()` that each time, just initialize it with that option: ```js // api/src/lib/jobs.js export const jobs = { // highlight-next-line - sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }) + sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }), } // api/src/services/users/users.js @@ -497,7 +507,7 @@ export const createUser = async ({ input }) => { // api/src/lib/jobs.js export const jobs = { // highlight-next-line - sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }) // 5 minutes + sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }), // 5 minutes } // api/src/services/users/users.js @@ -523,18 +533,18 @@ Once you have your instance you can inspect the options set on it: ```js const job = new SendWelcomeEmail() // set by RedwoodJob.config or static properies -job.adapter // => PrismaAdapter instance -jog.logger // => logger instance +job.adapter // => PrismaAdapter instance +jog.logger // => logger instance // set via `set()` or provided during job instantiaion -job.queue // => 'default' -job.priority // => 50 -job.wait // => 300 +job.queue // => 'default' +job.priority // => 50 +job.wait // => 300 job.waitUntil // => null // computed internally -job.runAt // => 2025-07-27 12:35:00 UTC - // ^ the actual computed Date of now + `wait` +job.runAt // => 2025-07-27 12:35:00 UTC +// ^ the actual computed Date of now + `wait` ``` :::info @@ -552,9 +562,9 @@ import { AnnualReportGenerationJob } from 'api/src/jobs/AnnualReportGenerationJo AnnualReportGenerationJob.performLater() // or -AnnualReportGenerationJob - .set({ waitUntil: new Date(2025, 0, 1) }) - .performLater() +AnnualReportGenerationJob.set({ + waitUntil: new Date(2025, 0, 1), +}).performLater() ``` Using this syntax comes with a caveat: since no `RedwoodJob.config()` was called you would need to configure the `adapter` and `logger` directly on `AnnualReportGenerationJob` via static properties (unless you were sure that `RedwoodJob.config()` was called somewhere before this code executes). See the note at the end of the [Exporting jobs](#exporting-jobs) section explaining this limitation. @@ -563,10 +573,10 @@ Using this syntax comes with a caveat: since no `RedwoodJob.config()` was called You can pass several options in a `set()` call on your instance or class: -* `wait`: number of seconds to wait before the job will run -* `waitUntil`: a specific `Date` in the future to run at -* `queue`: the named queue to put this job in (overrides any `static queue` set on the job itself) -* `priority`: the priority to give this job (overrides any `static priority` set on the job itself) +- `wait`: number of seconds to wait before the job will run +- `waitUntil`: a specific `Date` in the future to run at +- `queue`: the named queue to put this job in (overrides any `static queue` set on the job itself) +- `priority`: the priority to give this job (overrides any `static priority` set on the job itself) ## Job Runner @@ -586,7 +596,7 @@ This process will stay attached the console and continually look for new jobs an :::caution Long running jobs -It's currently up to you to make sure your job completes before your `maxRuntime` limit is reached! NodeJS Promises are not truly cancelable: you can reject early, but any Promises that were started *inside* will continue running unless they are also early rejected, recursively forever. +It's currently up to you to make sure your job completes before your `maxRuntime` limit is reached! NodeJS Promises are not truly cancelable: you can reject early, but any Promises that were started _inside_ will continue running unless they are also early rejected, recursively forever. The only way to guarantee a job will completely stop no matter what is for your job to spawn an actual OS level process with a timeout that kills it after a certain amount of time. We may add this functionality natively to Jobs in the near future: let us know if you'd benefit from this being built in! @@ -710,16 +720,16 @@ The job runner sets the `--maxAttempts`, `--maxRuntime` and `--sleepDelay` flags ### Flags -* `--id` : a number identifier that's set as part of the process name. For example starting a worker with * `--id=0` and then inspecting your process list will show one running named `rw-job-worker.0` -* `--queue` : the named queue for the worker to focus on. Leaving this flag off defaults to watching all queues. This will also effect the process title name. Setting `--queue=email` would lead to process named * `rw-job-worker.email.0` (assuming `--id=0`) -* `--workoff` : if this flag is present then the worker will terminate itself after the queue it is watching is empty -* `--clear` : if this flag is present the worker will call the `clear()` function on the adapter (which should remove all jobs in storage) and then exit -* `--maxAttempts` : The max number of times to retry a job before considering it failed, and will not be retried. Defaults to `24`. -* `--maxRuntime` : The max amount of time, in seconds, a job should run before being eligible to be picked up by another worker instead, effectively starting over. Defaults to `14400` which is 4 hours. -* `--sleepDelay` : How long to wait, in seconds, before querying the adapter for another available job to run. Careful not to DDoS your job storage system by setting this too low! -* `--deleteFailedJobs` : If `maxAttempts` is reached, what to do with the job. Defaults to `false` meaning the job will remain in the queue, but the workers will not pick it up to work on again. Useful for debugging why a job may have failed. -* `--adapter` : The name of the variable exported from `api/src/lib/jobs.js` to use as the adapter for this worker instance. Defalts to `adapter`. The worker will error on startup if this variable is not exported by your jobs config file. -* `--logger` : The name of the variable exported from `api/src/lib/jobs.js` to us as the logger for this worker instance. Defaults to `logger` but will fall back to `console` if no `logger` is exported. +- `--id` : a number identifier that's set as part of the process name. For example starting a worker with \* `--id=0` and then inspecting your process list will show one running named `rw-job-worker.0` +- `--queue` : the named queue for the worker to focus on. Leaving this flag off defaults to watching all queues. This will also effect the process title name. Setting `--queue=email` would lead to process named \* `rw-job-worker.email.0` (assuming `--id=0`) +- `--workoff` : if this flag is present then the worker will terminate itself after the queue it is watching is empty +- `--clear` : if this flag is present the worker will call the `clear()` function on the adapter (which should remove all jobs in storage) and then exit +- `--maxAttempts` : The max number of times to retry a job before considering it failed, and will not be retried. Defaults to `24`. +- `--maxRuntime` : The max amount of time, in seconds, a job should run before being eligible to be picked up by another worker instead, effectively starting over. Defaults to `14400` which is 4 hours. +- `--sleepDelay` : How long to wait, in seconds, before querying the adapter for another available job to run. Careful not to DDoS your job storage system by setting this too low! +- `--deleteFailedJobs` : If `maxAttempts` is reached, what to do with the job. Defaults to `false` meaning the job will remain in the queue, but the workers will not pick it up to work on again. Useful for debugging why a job may have failed. +- `--adapter` : The name of the variable exported from `api/src/lib/jobs.js` to use as the adapter for this worker instance. Defalts to `adapter`. The worker will error on startup if this variable is not exported by your jobs config file. +- `--logger` : The name of the variable exported from `api/src/lib/jobs.js` to us as the logger for this worker instance. Defaults to `logger` but will fall back to `console` if no `logger` is exported. ## Creating Your Own Adapter @@ -727,18 +737,18 @@ We'd love the community to contribue adapters for Redwood Job! Take a look at th The general gist of the required functions: -* `find()` should find a job to be run, lock it and return it (minimum return of and object containing `handler` and `args` properties) -* `schedule()` accepts `handler`, `args`, `runAt`, `queue` and `priority` and should store the job -* `success()` accepts the same job object returned from `find()` and does whatever success means (delete the job from the queue, most likely) -* `failure()` accepts the same job object returned from `find()` and does whatever failure means (unlock the job and reschedule a time for it to run again in the future, or give up if `maxAttempts` is reached) -* `clear()` remove all jobs from the queue (mostly used in development) +- `find()` should find a job to be run, lock it and return it (minimum return of and object containing `handler` and `args` properties) +- `schedule()` accepts `handler`, `args`, `runAt`, `queue` and `priority` and should store the job +- `success()` accepts the same job object returned from `find()` and does whatever success means (delete the job from the queue, most likely) +- `failure()` accepts the same job object returned from `find()` and does whatever failure means (unlock the job and reschedule a time for it to run again in the future, or give up if `maxAttempts` is reached) +- `clear()` remove all jobs from the queue (mostly used in development) ## The Future There's still more to add to background jobs! Our current TODO list: -* More adapters: Redis, SQS, RabbitMQ... -* RW Studio integration: monitor the state of your outstanding jobs -* Baremetal integration: if jobs are enabled, monitor the workers with pm2 -* Recurring jobs -* Livecycle hooks: `beforePerform()`, `afterPerform()`, `afterSuccess()`, `afterFailure()` +- More adapters: Redis, SQS, RabbitMQ... +- RW Studio integration: monitor the state of your outstanding jobs +- Baremetal integration: if jobs are enabled, monitor the workers with pm2 +- Recurring jobs +- Livecycle hooks: `beforePerform()`, `afterPerform()`, `afterSuccess()`, `afterFailure()` From 1506a9df0b2266a4c626ca36fed68ae1ba6e1d8f Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 19 Aug 2024 14:48:59 -0700 Subject: [PATCH 232/258] First draft of new jobs doc --- docs/docs/background-jobs.md | 447 +++++++++++++++-------------------- 1 file changed, 186 insertions(+), 261 deletions(-) diff --git a/docs/docs/background-jobs.md b/docs/docs/background-jobs.md index b47be8031f21..c51cf0ea203d 100644 --- a/docs/docs/background-jobs.md +++ b/docs/docs/background-jobs.md @@ -325,262 +325,157 @@ The rest of this doc describes more advanced usage, like: - Manually workers without the job runner monitoring them - And more! -## Job Configuration +## Configuration -Let's take a closer look at `api/src/lib/jobs.js`: +### JobManager Config -```js -import { PrismaAdapter, RedwoodJob } from '@redwoodjs/jobs' -import { db } from 'src/lib/db' -import { logger } from 'src/lib/logger' - -export const adapter = new PrismaAdapter({ db, logger }) - -RedwoodJob.config({ adapter, logger }) - -export const jobs = {} -``` - -### Exporting an `adapter` - -```js -export const adapter = new PrismaAdapter({ db, logger }) -``` - -This is the adapter that the job runner itself will use to run your jobs if you don't override the adapter in the job itself. In most cases this will be the same for all jobs, but just be aware that you can use different adapters for different jobs if you really want! Exporting an `adapter` in this file is required for the job runner to start. - -### Configuring All Jobs with `RedwoodJob.config` - -```js -RedwoodJob.config({ adapter, logger }) -``` - -This is the global config for all jobs. You can override these values in individual jobs, but if you don't want to this saves you a lot of extra code configuring individual jobs over and over again with the same adapter, logger, etc. - -Jobs will inherit a default queue name of `"default"` and a priority of `50`. - -Config can be given the following options: - -- `adapter`: **[required]** The adapter to use for scheduling your job. -- `logger`: Made available as `this.logger` within your job for logging whatever you'd like during the `perform()` step. This defaults to `console`. - -### Exporting `jobs` - -```js -export const jobs = {} -``` - -We've found a nice convention is to export instances of all of your jobs here. Then you only have a single import to use in other places when you want to schedule a job: +Let's take a closer look at the `jobs` export in `api/src/lib/jobs.js`: ```js -// api/src/lib/jobs.js -export const jobs = { - sendWelcomeEmailJob: new SendWelcomeEmailJob(), - // highlight-next-line - productBackorderJob: new ProductBackorderJob(), - inventoryReportGenJob: new InventoryReportGenJob(), -} - -// api/src/services/products/products.js -import { jobs } from 'api/src/lib/jobs' - -export const updateProduct = async ({ id, input }) => { - const product = await db.product.update({ where: { id }, data: input }) - // highlight-next-line - await jobs.productBackorderJob.performLater() - return product -} -``` - -It _is_ possible to skip this export altogther and import and schedule individual jobs manually: - -```js -// api/src/services/products/products.js -import { ProductBackorderJob } from 'api/src/jobs/ProductBackorderJob' - -export const updateProduct = async ({ id, input }) => { - const product = await db.product.update({ where: { id }, data: input }) - await ProductBackorderJob.performLater() - return product -} +export const jobs = new JobManager({ + adapters: { + prisma: new PrismaAdapter({ db, logger }), + }, + queues: ['default'], + logger, + workers: [ + { + adapter: 'prisma', + logger, + queue: '*', + count: 1, + maxAttempts: 24, + maxRuntime: 14_400, + deleteFailedJobs: false, + sleepDelay: 5, + }, + ], +}) ``` -HOWEVER, this will lead to unexpected behavior if you're not aware of the following: +The object passed here contains all of the configuration for the Background Job system. Let's take a quick look at the four top-level properties and then we'll get into more details in the subsections to follow. -:::danger +#### `adapters` -If you don't export a `jobs` object and then `import` it when you want to schedule a job, the `Redwood.config()` line will never be executed and your jobs will not receive a default configuration! This means you'll need to either: +This is the list of adapters that are available to handle storing and retreiving your jobs to and from the storage system. You could list more than one adapter here and then have multiple schedulers. Most folks will probably stick with a single one. -- Invoke `RedwoodJob.config()` somewhere before scheduling your job -- Manually set the adapter/logger/etc. in each of your jobs. +#### `queues` -We'll see examples of configuring the individual jobs with an adapter and logger below. +An array of available queue names that jobs can be placed in. By default, a single queue named "default" is listed here, and will also be the default queue for generated jobs. To denote the named queue that a worker will look at, there is a matching `queue` property on the `workers` config below. -::: - -## Job Configuration - -All jobs have some default configuration set for you if don't do anything different: +#### `logger` -- `queue` : jobs can be in named queues and have dedicated workers that only pull jobs from that queue. This lets you scale not only your entire job runner independently of the rest of your app, but scale the individual queues as well. By default, all jobs will go in a queue named "default" if you don't override it. -- `priority` : within a single queue you can jobs that are more or less important. The workers will pull jobs off the queue with a higher priority before working on ones with a lower priority. The default priority is `50`. A lower number is _higher_ in priority than a lower number. ie. the workers will work on a job with a priority of `1` before they work on one with a priority of `100`. -- `logger` : jobs will log to the console if you don't tell them otherwise. The logger exported from `api/src/lib/logger.js` works well the job runner, so we recommend using that by setting it in `RedwoodJob.config()` or on a per-job basis. -- `adapter` : the adapter to use to store this jobs. There is no default adapter set for jobs, so you'll need to set this in `RedwoodJob.config()` or on a per-job basis. +The logger object for all internal logging of the job system itself. You can use this same logger in the `perform()` function of your job (by referencing `jobs.logger`) and then all of your log message will be aggregated together. -If you don't do anything special, a job will inherit the adapter and logger you set with the call to `RedwoodJob.config()`. However, you can override these settings on a per-job basis. You don't have to set all of them, you can use them in any combination you want: +#### `workers` -```js -import { RedwoodJob, PrismaAdapter } from '@redwoodjs/jobs' -import { db } from 'api/src/lib/db' -import { emailLogger } from 'api/src/lib/logger' - -export const class SendWelcomeEmailJob extends RedwoodJob { - // highlight-start - static adapter = new PrismaAdapter({ db }) - static logger = emailLogger() - static queue = 'email' - static priority = '1' - // highlight-end - - perform(userId) => { - // ... send email ... - } -} -``` +This is an array of objects, each defining a "group" of workers. When will you need more than one group? If you need workers to work on different queues, or use different adapters. Read more about this in the [Job Workers](#job-workers) section. -## Adapter Configuration +### Adapter Config -Adapters accept an object of options when they are initialized. +Adapters are added as key/value pairs to the `adapters` object given to the `JobManager` upon initialization. The key of the property (like `prisma` in the example below) is the name you'll use in your scheduler when you tell it which adapter to use to schedule your jobs. Adapters accept an object of options when they are initialized. -### PrismaAdapter +#### PrismaAdapter ```js -import { db } from 'api/src/lib/db' - -const adapter = new PrismaAdapter({ - db, - model: 'BackgroundJob', - logger: console, +export const jobs = new JobManager({ + adapters: { + // highlight-next-line + prisma: new PrismaAdapter({ db, model: 'BackgroundJob', logger }), + }, + // remaining config... }) ``` - `db`: **[required]** an instance of `PrismaClient` that the adapter will use to store, find and update the status of jobs. In most cases this will be the `db` variable exported from `api/src/lib/db.js`. This must be set in order for the adapter to be initialized! -- `model`: the name of the model that was created to store jobs. This defaults to `BackgroundJob` -- `logger`: events that occur within the adapter will be logged using this. This defaults to `console` but the `logger` exported from `api/src/lib/logger` works great, too. +- `model`: the name of the model that was created to store jobs. This defaults to `BackgroundJob`. +- `logger`: events that occur within the adapter will be logged using this. This defaults to `console` but the `logger` exported from `api/src/lib/logger` works great. -## Job Scheduling +### Scheduler Config -The interface to schedule a job is fairly flexible: use the pattern you like best! By default the generators will assume you want to use the [Instance Invocation](#instance-invocation) pattern, but maybe the [Class Invocation](#class-invocation) speaks to your soul! - -### Instance Invocation - -Using this pattern you instantiate the job first, set any options, then schedule it: +When you create an instance of the scheduler you can pass it a couple of options: ```js -import { SendWelcomeEmailJob } from 'api/src/jobs/SendWelcomeEmailJob` - -const job = new SendWelcomeEmailJob() -job.set({ wait: 300 }).performLater() +export const later = jobs.createScheduler({ + adapter: 'prisma', +}) ``` -You can also set options when you create the instance. For example, if _every_ invocation of this job should wait 5 minutes, no need to `set()` that each time, just initialize it with that option: +- `adapter` : **[required]** the name of the adapter this scheudler will use to schedule jobs. Must be one of the keys that you gave to the `adapters` option on the JobManager itself. +- `logger` : the logger to use when scheduling jobs. If not provided, defaults to the `logger` set on the JobManager. -```js -// api/src/lib/jobs.js -export const jobs = { - // highlight-next-line - sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }), -} - -// api/src/services/users/users.js -export const createUser = async ({ input }) => { - const user = await db.user.create({ data: input }) - // highlight-next-line - await jobs.sendWelcomeEmail.performLater() - return user -} -``` +#### Scheduling Options -`set()` will merge together with any options set when initialized, so you can, for example, override those settings on a one-off basis: +When using the scheduler to scheduled a job you can pass options in an optional third argument: ```js -// api/src/lib/jobs.js -export const jobs = { - // highlight-next-line - sendWelcomeEmail: new SendWelcomeEmailJob({ wait: 300 }), // 5 minutes -} - -// api/src/services/users/users.js -export const createUser = async ({ input }) => { - const user = await db.user.create({ data: input }) - // highlight-next-line - await jobs.sendWelcomeEmail.set({ wait: 3600 }).performLater() // 1 hour - return user -} +later(SampleJob, [user.id], { wait: 300 }) ``` -You can also do the setting separate from the scheduling: +- `wait`: number of seconds to wait before the job will run +- `waitUntil`: a specific `Date` in the future to run at -```js -const job = new SendWelcomeEmailJob() -job.set({ wait: 300 }) -// do something else... -job.performLater() -``` +### Job Config -Once you have your instance you can inspect the options set on it: +There are two configuration options you can define in the object that describes your job: ```js -const job = new SendWelcomeEmail() -// set by RedwoodJob.config or static properies -job.adapter // => PrismaAdapter instance -jog.logger // => logger instance - -// set via `set()` or provided during job instantiaion -job.queue // => 'default' -job.priority // => 50 -job.wait // => 300 -job.waitUntil // => null +import { jobs } from 'src/lib/jobs' -// computed internally -job.runAt // => 2025-07-27 12:35:00 UTC -// ^ the actual computed Date of now + `wait` +export const SendWelcomeEmailJob = jobs.createJob({ + // highlight-start + queue: 'email', + priority: 1, + // hightlightend + perform: async (userId) => { + // job details... + }, +}) ``` -:::info - -You can't set these values directly, that's done through `set()`. Also, you can never set `runAt`, that's computed internally. If you want your job to run at a specific time you can use the `waitUntil` option, See [Scheduling Options](#scheduling-options) below. - -::: +- `queue` : **[required]** the name of the queue that this job will be placed in. Must be one of the strings you assigned to `queues` array when you set up the `JobManager`. +- `priority` : within a single queue you can have jobs that are more or less important. The workers will pull jobs off the queue with a higher priority before working on ones with a lower priority. The default priority is `50`. A lower number is _higher_ in priority than a lower number. ie. the workers will work on a job with a priority of `1` before they work on one with a priority of `100`. -### Class Invocation +### Worker Config -You can schedule a job directly, without instantiating it first: +This is the largest section of the `JobManager` config object. This options tell the workers how to behave when looking for and executing jobs. ```js -import { AnnualReportGenerationJob } from 'api/src/jobs/AnnualReportGenerationJob' +export const jobs = new JobManager({ + // .. more config + workers: [ + { + adapter: 'prisma', + logger, + queue: '*', + count: 1, + maxAttempts: 24, + maxRuntime: 14_400, + deleteFailedJobs: true, + deleteFailedJobs: false, + sleepDelay: 5, + }, + ], -AnnualReportGenerationJob.performLater() -// or -AnnualReportGenerationJob.set({ - waitUntil: new Date(2025, 0, 1), -}).performLater() ``` -Using this syntax comes with a caveat: since no `RedwoodJob.config()` was called you would need to configure the `adapter` and `logger` directly on `AnnualReportGenerationJob` via static properties (unless you were sure that `RedwoodJob.config()` was called somewhere before this code executes). See the note at the end of the [Exporting jobs](#exporting-jobs) section explaining this limitation. - -### Scheduling Options +This is an array of objects. Each object represents the config for a single "group" of workers. By default, there is only one worker group. It uses the `PrismaAdapter` and will look for jobs in all queues. If you want to start fine tuning your workers by working with different adapters, or only working on some named queues, you can add additional members to this array, each with a unique set of options. -You can pass several options in a `set()` call on your instance or class: +- `adapter` : **[required]** the name of the adapter this worker group will use. Must be one of the keys that you gave to the `adapters` option on the `JobManager` itself. +- `logger` : the logger to use when working on jobs. If not provided, defaults to the `logger` set on the `JobManager`. You can use this logger in the `perform()` function of your job by accessing `jobs.logger` +- queue : **[required]** the named queue(s) in which this worker group will watch for jobs. There is a reserved `'*'` value you can use which means "all queues." This can be an array of queues as well: `['default', 'email']` for example. +- `count` : **required** the number of workers to start using this config. +- `maxAttempts`: the maximum number of times to retry a job before giving up. Retries are handled with an exponential backoff in retry time, equal to the number of previous attempts \*\* 4. After this number, a job is considered "failed" and will not be re-attempted. Default: `24` +- `maxRuntime` : the maximum amount of time, in seconds, to try running a job before another worker will pick it up and try again. It's up to you to make sure your job doesn't run for longer than this amount of time! Default: 14,400 seconds (4 hours) +- `deleteFailedJobs` : when a job has failed (maximum number of retries has occured) you can keep the job in the database, or delete it. Default: `false` +- `deleteSuccessfulobs` : when a job has succeeded, you can keep the job in the database, or delete it. It's generally assumed that your jobs _will_ succeed so it usually makes sense to clear them out and keep the queue lean. Default: `true` +- `sleepDelay` : the amount of time, in seconds, to check the queue for another job to run. Too low and you'll be thrashing your storage system looking for jobs, too high and you start to have a long delay before any job is run. Default: `5` -- `wait`: number of seconds to wait before the job will run -- `waitUntil`: a specific `Date` in the future to run at -- `queue`: the named queue to put this job in (overrides any `static queue` set on the job itself) -- `priority`: the priority to give this job (overrides any `static priority` set on the job itself) +See the next section for advanced usage examples, like multiple worker groups. -## Job Runner +## Job Workers -The job runner actually executes your jobs. The runners will ask the adapter to find a job to work on. The adapter will mark the job as locked (the process name and a timestamp is set on the job) and then the worker will instantiate the job class and call `perform()` on it, passing in any args that were given to `performLater()` +A job worker actually executes your jobs. The workers will ask the adapter to find a job to work on. The adapter will mark the job as locked (the process name and a timestamp is set on the job) and then the worker will call `perform()` on your job, passing in any args that were given to `performLater()`. The behavior of what happens when the job succeeds or fails depends on the config options you set in the `JobManager`. By default, successful jobs are removed from storage and failed jobs and kept around so you can diagnose what happened. The runner has several modes it can start in depending on how you want it to behave. @@ -610,7 +505,13 @@ yarn rw jobs workoff As soon as there are no more jobs to be executed (either the store is empty, or they are scheduled in the future) the process will automatically exit. -By default this worker will work on all queues, but if you only wanted it to work on a specific one even in dev, check out the `-n` flag described in the [Multiple Workers](#multiple-workers) section below. +### Clearing the Job Queue + +You can remove all jobs from storage with: + +```bash +yarn rw jobs clear +``` ### Production Modes @@ -636,49 +537,76 @@ yarn rw jobs restart ### Multiple Workers -You can start more than one worker with the `-n` flag: - -```bash -yarn rw jobs start -n 4 -``` - -That starts 4 workers watching all queues. To only watch a certain queue, you can combine the queue name with the number that should start, separated by a `:`: - -```bash -yarn rw jobs start -n email:2 -``` - -That starts 2 workers that only watch the `email` queue. To have multiple workers watching separate named queues, separate those with commas: +With the default configuration options generated with the `yarn rw setup jobs` command, you'll have one worker group. If you simply want more workers that use the same `adapter` and `queue` settings, just increase the `count`: -```bash -yarn rw jobs start -n default:2,email:4 +```js +export const jobs = new JobManager({ + adapters: { + prisma: new PrismaAdapter({ db, logger }), + }, + queues: ['default'], + logger, + workers: [ + { + adapter: 'prisma', + logger, + queue: '*', + // highlight-next-line + count: 5, + maxAttempts: 24, + maxRuntime: 14_400, + deleteFailedJobs: false, + sleepDelay: 5, + }, + ], +}) ``` -That starts 2 workers watching the `default` queue and 4 watching `email`. - -If you want to combine named queues and all queues, leave off the name: +Now you have 5 workers. If you want to have separate workers working on separate queues, create another worker config object with a different queue name: -```bash -yarn rw jobs start -n :2,email:4 +```js +export const jobs = new JobManager({ + adapters: { + prisma: new PrismaAdapter({ db, logger }), + }, + queues: ['default'], + logger, + workers: [ + { + adapter: 'prisma', + logger, + // highlight-start + queue: 'default', + // highlight-end + count: 1, + maxAttempts: 24, + maxRuntime: 14_400, + deleteFailedJobs: false, + sleepDelay: 5, + }, + { + adapter: 'prisma', + logger, + // highlight-start + queue: 'email', + count: 1, + maxAttempts: 1, + maxRuntime: 30, + deleteFailedJobs: true, + // highlight-end + sleepDelay: 5, + }, + ], +}) ``` -2 workers watching all queues and another 4 dedicated to only `email`. - -### Stopping Multiple Workers - -Make sure you pass the same `-n` flag to the `stop` process as the `start` so it knows which ones to stop. The same with the `restart` command. - -### Monitoring the Workers +Here, we have 2 workers working on the "default" queue and 1 worker looking at the "email" queue (which will only try a job once, only wait 30 seconds for it to finish, and delete the job if it fails). You can also have different worker groups using different adapters. For example, you may have store and work on some jobs in your database using the `PrismaAdapter` and some jobs/workers using a `RedisAdapter`. -In production you'll want to hook the workers up to a process monitor since, just like with any other process, they could die unexpectedly. - -### Clear +:::info -You can remove all jobs from storage with: +We don't currently provide a `RedisAdapter` but plan to add one soon! -```bash -yarn rw jobs clear -``` +::: ## Deployment @@ -688,8 +616,6 @@ For many use cases you may simply be able to rely on the job runner to start you yarn rw jobs start ``` -See the options available in [Job Runner > Production Modes](#production-modes) for additional flags. - When you deploy new code you'll want to restart your runners to make sure they get the latest soruce files: ```bash @@ -702,34 +628,32 @@ For maximum reliability you should take a look at the [Advanced Job Workers](#ad :::info -Of course if you have a process monitor system watching your workers you'll to use the process monitor's version of the restart command each time you deploy! +Of course if you have a process monitor system watching your workers you'll to use the process monitor's version of the `restart` command each time you deploy! ::: ## Advanced Job Workers -In the cases above, all workers will use the same `adapter`, `logger` and `workerConfig` exported from your jobs config file at `api/src/lib/jobs.js`. However, it's possible to have workers running completely different configurations. To do this, you'll need to start the worker directly, instead of having the build-in job runner start them for you. +As noted above, although the workers are started and detached using the `yarn rw jobs start` command, there is nothing to monitor those workers to make sure they keep running. To do that, you'll want to start the workers yourself (or have your process monitor start them) using command line flags. -To start the worker yourself you'll run the `yarn rw-jobs-worker` command. As an example, to start a worker with the same configruation options that `yarn rw jobs work` would use, you'd run the following command: +You can do this with the `yarn rw-jobs-worker` command. To start a single worker, using the first `workers` config object, would run: ```bash -yarn rw-jobs-worker --id=0 --maxAttempts=24 --maxRuntime=14400 --sleepDelay 5 --adapter=adapter --logger=logger +yarn rw-jobs-worker --index=0 --id=0 ``` -The job runner sets the `--maxAttempts`, `--maxRuntime` and `--sleepDelay` flags based on the values in the `workerConfig` variable. The `--adapter` and `--logger` flags denote the name of the exported variables that the worker should load in order to set itself up to run jobs. - ### Flags -- `--id` : a number identifier that's set as part of the process name. For example starting a worker with \* `--id=0` and then inspecting your process list will show one running named `rw-job-worker.0` -- `--queue` : the named queue for the worker to focus on. Leaving this flag off defaults to watching all queues. This will also effect the process title name. Setting `--queue=email` would lead to process named \* `rw-job-worker.email.0` (assuming `--id=0`) -- `--workoff` : if this flag is present then the worker will terminate itself after the queue it is watching is empty -- `--clear` : if this flag is present the worker will call the `clear()` function on the adapter (which should remove all jobs in storage) and then exit -- `--maxAttempts` : The max number of times to retry a job before considering it failed, and will not be retried. Defaults to `24`. -- `--maxRuntime` : The max amount of time, in seconds, a job should run before being eligible to be picked up by another worker instead, effectively starting over. Defaults to `14400` which is 4 hours. -- `--sleepDelay` : How long to wait, in seconds, before querying the adapter for another available job to run. Careful not to DDoS your job storage system by setting this too low! -- `--deleteFailedJobs` : If `maxAttempts` is reached, what to do with the job. Defaults to `false` meaning the job will remain in the queue, but the workers will not pick it up to work on again. Useful for debugging why a job may have failed. -- `--adapter` : The name of the variable exported from `api/src/lib/jobs.js` to use as the adapter for this worker instance. Defalts to `adapter`. The worker will error on startup if this variable is not exported by your jobs config file. -- `--logger` : The name of the variable exported from `api/src/lib/jobs.js` to us as the logger for this worker instance. Defaults to `logger` but will fall back to `console` if no `logger` is exported. +- `--index` : a number that represents the index of the `workers` config option you passed to the `JobManager`. Setting this to `0`, for example, uses the first object in the array to set all config options for the worker. +- `--id` : a number identifier that's set as part of the process name. For example, starting a worker with `--id=0` and then inspecting your process list will show one running named `rw-job-worker.queue-name.0`. Using `yarn rw-jobs-worker` only ever starts a single instance, so if your config had a `count` of `2` you'd need to run the command twice, one with `--id=0` and a second time with `--id=1`. +- `--workoff` : a boolean that will execute all currently available jobs and then cause the worker to exit. Defaults to `false` +- `--clear` : a boolean that will remove all jobs from the queue. Defaults to `false` + +Your process monitor can now restart the processes automatically if they crash. + +### What Happens if a Worker Crashes? + +If a worker crashes because of circumstances outside of our control, the job will remained locked in the storage system since the worker couldn't finish work and clean it up itself. In this case, the job will be picked up again after `maxRuntime` has expired—a new worker will pick it up and re-lock using it's own indentification. ## Creating Your Own Adapter @@ -737,11 +661,12 @@ We'd love the community to contribue adapters for Redwood Job! Take a look at th The general gist of the required functions: -- `find()` should find a job to be run, lock it and return it (minimum return of and object containing `handler` and `args` properties) -- `schedule()` accepts `handler`, `args`, `runAt`, `queue` and `priority` and should store the job -- `success()` accepts the same job object returned from `find()` and does whatever success means (delete the job from the queue, most likely) -- `failure()` accepts the same job object returned from `find()` and does whatever failure means (unlock the job and reschedule a time for it to run again in the future, or give up if `maxAttempts` is reached) -- `clear()` remove all jobs from the queue (mostly used in development) +- `find()` should find a job to be run, lock it and return it (minimum return of an object containing `id`, `name`, `path`, and `args` properties) +- `schedule()` accepts `name`, `path`, `args`, `runAt`, `queue` and `priority` and should store the job +- `success()` accepts the same job object returned from `find()` and the `deleteSuccessfulJobs` boolean Does whatever success means to you (remove the job from storage, perhaps) +- `error()` accepts the same job object returned from `find()` and an error instance. Does whatever failure means to you (unlock the job and reschedule a time for it to run again in the future, or give up if `maxAttempts` is reached) +- `failure()` is called when the job has reached `maxAttempts` and accepts the job object and the `deleteFailedJobs` boolean. +- `clear()` remove all jobs from the queue (mostly used in development). ## The Future From e0c198e47fe4cbb641eac18fc920c185f361190c Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 19 Aug 2024 14:49:05 -0700 Subject: [PATCH 233/258] Update script examples --- packages/jobs/src/bins/rw-jobs.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index 55cc6bb8b4b9..034fc8ee75b3 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -38,12 +38,12 @@ const parseArgs = (argv: string[]) => { .command('clear', 'Clear the job queue') .demandCommand(1, 'You must specify a mode to start in') .example( - '$0 start -n 2', - 'Start the job runner with 2 workers in daemon mode', + '$0 work', + 'Start the job workers using the job config and work on jobs until manually stopped', ) .example( - '$0 start -n default:2,email:1', - 'Start the job runner in daemon mode with 2 workers for the "default" queue and 1 for the "email" queue', + '$0 start', + 'Start the job workers using the job config and detach, running in daemon mode', ) .help().argv From 5696a39ad34081cf53b48dc23fcfa11a6e358b88 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 19 Aug 2024 15:10:02 -0700 Subject: [PATCH 234/258] Doc updates --- docs/docs/background-jobs.md | 74 ++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 25 deletions(-) diff --git a/docs/docs/background-jobs.md b/docs/docs/background-jobs.md index c51cf0ea203d..98a90d08a80b 100644 --- a/docs/docs/background-jobs.md +++ b/docs/docs/background-jobs.md @@ -1,6 +1,6 @@ # Background Jobs -No one likes waiting in line. This is especially true of your website: users don't want to wait for things to load that don't directly impact the task they're trying to accomplish. For example, sending a "welcome" email when a new user signs up. The process of sending the email could as long or longer than the sum total of everything else that happens during that request. Why make the user wait for it? As long as they eventually get the email, everything is good. +No one likes waiting in line. This is especially true of your website: users don't want to wait for things to load that don't directly impact the task they're trying to accomplish. For example, sending a "welcome" email when a new user signs up. The process of sending the email could take as long or longer than the sum total of everything else that happens during that request. Why make the user wait for it? As long as they eventually get the email, everything is good. ## Concepts @@ -26,13 +26,15 @@ The job is completely self-contained and has everything it needs to perform its - delay for an amount of time before running - run at a specific datetime in the future -**Storage** is necessary so that your jobs are decoupled from your running application. With the included `PrismaAdapter` jobs are stored in your database. This allows you to scale everything independently: the api server (which is scheduling jobs), the database (which is storing the jobs ready to be run), and the job workers (which are executing the jobs). +**Storage** is necessary so that your jobs are decoupled from your running application. The job system interfaces with storage via an **adapter**. With the included `PrismaAdapter`, jobs are stored in your database. This allows you to scale everything independently: the api server (which is scheduling jobs), the database (which is storing the jobs ready to be run), and the job workers (which are executing the jobs). -**Execution** is handled by a job worker, which takes a job from storage, executes it, and then does something with the result, whether it was a success or failure. +**Execution** is handled by a **job worker**, which takes a job from storage, executes it, and then does something with the result, whether it was a success or failure. :::info Job execution time is never guaranteed -When scheduling a job, you're really saying "this is the earliest possible time I want this job to run": based on what other jobs are in the queue, and how busy the workers are, they may not get a chance to execute this one particiular job for an indeterminate amount of time. The only thing that's guaranteed is that it won't run any _earlier_ than the time you specify. +When scheduling a job, you're really saying "this is the earliest possible time I want this job to run": based on what other jobs are in the queue, and how busy the workers are, they may not get a chance to execute this one particiular job for an indeterminate amount of time. + +The only thing that's guaranteed is that a job won't run any _earlier_ than the time you specify. ::: @@ -49,7 +51,7 @@ yarn rw setup jobs yarn rw prisma migrate dev ``` -This created `api/src/lib/jobs.js` (or `.ts`) with a sample config. You can leave this as is for now. +This created `api/src/lib/jobs.js` (or `.ts`) with a sensible default config. You can leave this as is for now. ### Create a Job @@ -78,9 +80,10 @@ You'll most likely be scheduling work as the result of one of your service funct ```js title="api/src/services/users/users.js" import { db } from 'src/lib/db' -// highlight-next-line +// highlight-start import { later } from 'src/lib/jobs' import { SampleJob } from 'src/jobs/SampleJob' +// highlight-end export const createUser = ({ input }) => { const user = await db.user.create({ data: input }) @@ -90,9 +93,7 @@ export const createUser = ({ input }) => { } ``` -The second argument is an array of all the arguments your job should receive. The job itself defines them as normal, named arguments (like `userId`), but when you schedule you wrap them in an array (like `[user.id]`). - -The third argument is an optional object that defines a couple of options. In this case, the number of seconds to `wait` before this job will be run (60 seconds). +The first argument is the job itself, the second argument is an array of all the arguments your job should receive. The job itself defines them as normal, named arguments (like `userId`), but when you schedule you wrap them in an array (like `[user.id]`). The third argument is an optional object that provides a couple of options. In this case, the number of seconds to `wait` before this job will be run (60 seconds). If you check your database you'll see your job is now listed in the `BackgroundJob` table: @@ -108,7 +109,7 @@ yarn rw jobs work This process will stay attached to the terminal and show you debug log output as it looks for jobs to run. Note that since we scheduled our job to wait 60 seconds before running, the runner will not find a job to work on right away (unless it's already been a minute since you scheduled it!). -That's the basics of jobs! Keep reading to get the details, including how you to run your job runners in production. +That's the basics of jobs! Keep reading to get a more detailed walkthrough, followed by the API docs listing all the various options. We'll wrap up with a discussion of using jobs in a production environment. ## In-Depth Start @@ -128,7 +129,26 @@ This will add a new model to your Prisma schema, and create a configuration file yarn rw prisma migrate dev ``` -Let's look at the config file. Comments have been removed for brevity: +This added the following model: + +```prisma +model BackgroundJob { + id Int @id @default(autoincrement()) + attempts Int @default(0) + handler String + queue String + priority Int + runAt DateTime? + lockedAt DateTime? + lockedBy String? + lastError String? + failedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +Let's look at the config file that was generated. Comments have been removed for brevity: ```js import { PrismaAdapter, JobManager } from '@redwoodjs/jobs' @@ -161,9 +181,9 @@ export const later = jobs.createScheduler({ }) ``` -Two variables are exported: one is an instance of the `JobManager` called `jobs` on which you'll call functions to create jobs. The other is `later` which is an instance of the `Scheduler`, which is responsible for getting your job into the storage system (out of the box this will be the database thanks to the `PrismaAdapter`). +Two variables are exported: one is an instance of the `JobManager` called `jobs` on which you'll call functions to create jobs and schedulers. The other is `later` which is an instance of the `Scheduler`, which is responsible for getting your job into the storage system (out of the box this will be the database thanks to the `PrismaAdapter`). -We'll go into more detail on this file later (see [Job Configuration](#job-configuration)), but what's there now is fine to get started creating a job. +We'll go into more detail on this file later (see [JobManager Config](#jobmanager-config)), but what's there now is fine to get started creating a job. ### Creating New Jobs @@ -173,11 +193,7 @@ We have a generator that creates a job in `api/src/jobs`: yarn rw g job SendWelcomeEmail ``` -Jobs are defined as a plain object and given to the `createJob()` function (which is called on the `jobs` export in the config file above). - -At a minimum, a job must contain the name of the `queue` the job should be saved to, and a function named `perform()` which contains the logic for your job. You can add additional properties to the object to support the task your job is performing, but `perform()` is what's invoked by the job worker that we'll see later. - -An example `SendWelcomeEmailJob` may look something like: +Jobs are defined as a plain object and given to the `createJob()` function (which is called on the `jobs` export in the config file above). An example `SendWelcomeEmailJob` may look something like: ```js import { db } from 'src/lib/db' @@ -195,7 +211,9 @@ export const SendWelcomeEmailJob = jobs.createJob({ }) ``` -Note that `perform()` can take any arguments you want, but it's a best practice to keep them as simple as possible. With the `PrismaAdapter` the arguments are stored in the database, so the list of arguments must be serializable to and from a string of JSON. +At a minimum, a job must contain the name of the `queue` the job should be saved to, and a function named `perform()` which contains the logic for your job. You can add additional properties to the object to support the task your job is performing, but `perform()` is what's invoked by the job worker that we'll see later. + +Note that `perform()` can take any argument(s)s you want (or none at all), but it's a best practice to keep them as simple as possible. With the `PrismaAdapter` the arguments are stored in the database, so the list of arguments must be serializable to and from a string of JSON. :::info Keeping Arguments Simple @@ -218,8 +236,10 @@ export const later = jobs.createScheduler({ You call this function, passing the job, job arguments, and an optional options object when you want to schedule a job. Let's see how we'd schedule our welcome email to go out when a new user is created: ```js -// highlight-next-line +// highlight-start +import { later } from 'src/lib/jobs' import { SendWelcomeEmailJob } from 'src/jobs/SendWelcomeEmailJob' +// highlight-end export const createUser = async ({ input }) { const user = await db.user.create({ data: input }) @@ -247,7 +267,7 @@ later(MilleniumAnnouncementJob, [user.id], { As noted in the [Concepts](#concepts) section, a job is never _guaranteed_ to run at an exact time. The worker could be busy working on other jobs and can't get to yours just yet. -If you absolutely, positively need your job to run right _now_ you can call your job's `perform` function directly in your code: +If you absolutely, positively need your job to run right _now_ (with the knowledge that the user will be waiting for it to complete) you can call your job's `perform` function directly in your code: ```js await SampleEmailJob.perform(user.id) @@ -274,14 +294,14 @@ If we were to query the `BackgroundJob` table after the job has been scheduled y } ``` -The `handler` field contains the name of the job, file path to find it, and the arguments its `perform()` function will receive. - :::info Because we're using the `PrismaAdapter` here all jobs are stored in the database, but if you were using a different storage mechanism via a different adapter you would have to query those in a manner specific to that adapter's backend. ::: +The `handler` column contains the name of the job, file path to find it, and the arguments its `perform()` function will receive. Where did the `name` and `path` come from? We have a babel plugin that adds them to your job when they are built. + ### Executing Jobs In development you can start a job worker via the **job runner** from the command line: @@ -290,7 +310,7 @@ In development you can start a job worker via the **job runner** from the comman yarn rw jobs work ``` -The runner is a sort of overseer that doesn't do any work itself, but spawns workers to actually execute the jobs. When starting in `work` mode a single worker will spin up and stay attached to the terminal and update you on the status of what it's doing: +The runner is a sort of overseer that doesn't do any work itself, but spawns workers to actually execute the jobs. When starting in `work` mode your `workers` config will be used to start the workers and they will stay attached to the terminal, updating you on the status of what they're doing: ![image](/img/background-jobs/jobs-terminal.png) @@ -298,6 +318,8 @@ It checks the `BackgroundJob` table every few seconds for a new job and, if it f If the job succeeds then by default it's removed the database (using the `PrismaAdapter`, other adapters behavior may vary). If the job fails, the job is un-locked in the database, the `runAt` is set to an incremental backoff time in the future, and `lastError` is updated with the error that occurred. The job will now be picked up in the future once the `runAt` time has passed and it'll try again. +To stop the runner (and the workers it started), press `Ctrl-C` (or send `SIGINT`). The workers will gracefully shut down, waiting for their work to complete before exiting. If you don't wait to wait, hit `Ctrl-C` again (or send `SIGTERM`), + There are a couple of additional modes that `rw jobs` can run in: ```bash @@ -327,6 +349,8 @@ The rest of this doc describes more advanced usage, like: ## Configuration +There are a bunch of ways to customize your jobs and the workers. + ### JobManager Config Let's take a closer look at the `jobs` export in `api/src/lib/jobs.js`: @@ -365,7 +389,7 @@ An array of available queue names that jobs can be placed in. By default, a sing #### `logger` -The logger object for all internal logging of the job system itself. You can use this same logger in the `perform()` function of your job (by referencing `jobs.logger`) and then all of your log message will be aggregated together. +The logger object for all internal logging of the job system itself and will fall back to `console` if you don't set it. #### `workers` From cd9fc6b625e19feed9bf63b043d710333959f9c9 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 19 Aug 2024 17:29:39 -0700 Subject: [PATCH 235/258] Jobs docs looking good --- docs/docs/background-jobs.md | 140 +++++++++++------- .../img/background-jobs/jobs-queues.png | Bin 0 -> 145259 bytes .../img/background-jobs/jobs-workers.png | Bin 0 -> 231526 bytes 3 files changed, 90 insertions(+), 50 deletions(-) create mode 100644 docs/static/img/background-jobs/jobs-queues.png create mode 100644 docs/static/img/background-jobs/jobs-workers.png diff --git a/docs/docs/background-jobs.md b/docs/docs/background-jobs.md index 98a90d08a80b..39f68be820ac 100644 --- a/docs/docs/background-jobs.md +++ b/docs/docs/background-jobs.md @@ -14,7 +14,11 @@ If we want the email to be send asynchonously, we can shuttle that process off i The user's response is returned much quicker, and the email is sent by another process, literally running in the background. All of the logic around sending the email is packaged up as a **job** and a **job worker** is responsible for executing it. -The job is completely self-contained and has everything it needs to perform its task. There are three components to the background job system in Redwood: +Each job is completely self-contained and has everything it needs to perform its own task. + +### Overview + +There are three components to the background job system in Redwood: 1. Scheduling 2. Storage @@ -38,9 +42,21 @@ The only thing that's guaranteed is that a job won't run any _earlier_ than the ::: +### Queues + +Jobs are organized by a named **queue**. This is simply a string and has no special significance, other than letting you group jobs. Why group them? So that you can potentially have workers with different configruations working on them. Let's say you send a lot of emails, and you find that among all your other jobs, emails are starting to be noticeably delayed when sending. You can start assigning those jobs to the "email" queue and create a new worker group that _only_ focuses on jobs in that queue so that they're sent in a more timely manner. + +Jobs are sorted by **priority** before being selected to be worked on. Lower numbers mean higher priority: + +![image](/img/background-jobs/jobs-queues.png) + +You can also increase the number of workers in a group. If we bumped the group working on the "default" queue to 2 and started our new "email" group with 1 worker, once those workers started we would see them working on the following jobs: + +![image](/img/background-jobs/jobs-workers.png) + ## Quick Start -We'll get into more detail later, but if you just to get up and running quickly, here we go. +Start here if you want to get up and running with jobs as quickly as possible and worry about the details later. ### Setup @@ -95,10 +111,6 @@ export const createUser = ({ input }) => { The first argument is the job itself, the second argument is an array of all the arguments your job should receive. The job itself defines them as normal, named arguments (like `userId`), but when you schedule you wrap them in an array (like `[user.id]`). The third argument is an optional object that provides a couple of options. In this case, the number of seconds to `wait` before this job will be run (60 seconds). -If you check your database you'll see your job is now listed in the `BackgroundJob` table: - -![image](/img/background-jobs/jobs-db.png) - ### Executing a Job Start the worker process to find jobs in the DB and execute them: @@ -275,23 +287,27 @@ await SampleEmailJob.perform(user.id) ::: -If we were to query the `BackgroundJob` table after the job has been scheduled you'd see a new row: +If we were to query the `BackgroundJob` table after the job has been scheduled you'd see a new row. We can use the Redwood Console to query the table from the command line: ```js -{ - id: 132, - attempts: 0, - handler: '{"name":"SendWelcomeEmailJob",path:"SendWelcomeEmailJob/SendWelcomeEmailJob","args":[335]}', - queue: 'default', - priority: 50, - runAt: 2024-07-12T22:27:51.085Z, - lockedAt: null, - lockedBy: null, - lastError: null, - failedAt: null, - createdAt: 2024-07-12T22:27:51.125Z, - updatedAt: 2024-07-12T22:27:51.125Z -} +% yarn rw console +> db.backgroundJob.findMany() +[ + { + id: 1, + attempts: 0, + handler: '{"name":"SendWelcomeEmailJob",path:"SendWelcomeEmailJob/SendWelcomeEmailJob","args":[335]}', + queue: 'default', + priority: 50, + runAt: 2024-07-12T22:27:51.085Z, + lockedAt: null, + lockedBy: null, + lastError: null, + failedAt: null, + createdAt: 2024-07-12T22:27:51.125Z, + updatedAt: 2024-07-12T22:27:51.125Z + } +] ``` :::info @@ -302,6 +318,12 @@ Because we're using the `PrismaAdapter` here all jobs are stored in the database The `handler` column contains the name of the job, file path to find it, and the arguments its `perform()` function will receive. Where did the `name` and `path` come from? We have a babel plugin that adds them to your job when they are built. +:::warning Jobs Must Be Built + +Jobs are run from the `api/dist` directory, which will exist only after running `yarn rw build api` or `yarn rw dev`. If you are working on a job in development, you're probably running `yarn rw dev` anyway. But just be aware that if the dev server is _not_ running then any changes to your job will not be reflected unless you run `yarn rw build api` (or start the dev server) to compile your job into `api/dist`. + +::: + ### Executing Jobs In development you can start a job worker via the **job runner** from the command line: @@ -426,11 +448,11 @@ export const later = jobs.createScheduler({ ``` - `adapter` : **[required]** the name of the adapter this scheudler will use to schedule jobs. Must be one of the keys that you gave to the `adapters` option on the JobManager itself. -- `logger` : the logger to use when scheduling jobs. If not provided, defaults to the `logger` set on the JobManager. +- `logger` : the logger to use for this instance of the scheduler. If not provided, defaults to the `logger` set on the `JobManager`. #### Scheduling Options -When using the scheduler to scheduled a job you can pass options in an optional third argument: +When using the scheduler to schedule a job you can pass options in an optional third argument: ```js later(SampleJob, [user.id], { wait: 300 }) @@ -439,6 +461,8 @@ later(SampleJob, [user.id], { wait: 300 }) - `wait`: number of seconds to wait before the job will run - `waitUntil`: a specific `Date` in the future to run at +If you don't pass any options then the job will be defaulted to run as soon as possible, ie: `new Date()` + ### Job Config There are two configuration options you can define in the object that describes your job: @@ -450,7 +474,7 @@ export const SendWelcomeEmailJob = jobs.createJob({ // highlight-start queue: 'email', priority: 1, - // hightlightend + // highlight-end perform: async (userId) => { // job details... }, @@ -458,11 +482,11 @@ export const SendWelcomeEmailJob = jobs.createJob({ ``` - `queue` : **[required]** the name of the queue that this job will be placed in. Must be one of the strings you assigned to `queues` array when you set up the `JobManager`. -- `priority` : within a single queue you can have jobs that are more or less important. The workers will pull jobs off the queue with a higher priority before working on ones with a lower priority. The default priority is `50`. A lower number is _higher_ in priority than a lower number. ie. the workers will work on a job with a priority of `1` before they work on one with a priority of `100`. +- `priority` : within a queue you can have jobs that are more or less important. The workers will pull jobs off the queue with a higher priority before working on ones with a lower priority. A lower number is _higher_ in priority than a lower number. ie. the workers will work on a job with a priority of `1` before they work on one with a priority of `100`. If you don't override it here, the default priority is `50`. ### Worker Config -This is the largest section of the `JobManager` config object. This options tell the workers how to behave when looking for and executing jobs. +This is the largest section of the `JobManager` config object. This options array tell the workers how to behave when looking for and executing jobs. ```js export const jobs = new JobManager({ @@ -488,18 +512,18 @@ This is an array of objects. Each object represents the config for a single "gro - `adapter` : **[required]** the name of the adapter this worker group will use. Must be one of the keys that you gave to the `adapters` option on the `JobManager` itself. - `logger` : the logger to use when working on jobs. If not provided, defaults to the `logger` set on the `JobManager`. You can use this logger in the `perform()` function of your job by accessing `jobs.logger` - queue : **[required]** the named queue(s) in which this worker group will watch for jobs. There is a reserved `'*'` value you can use which means "all queues." This can be an array of queues as well: `['default', 'email']` for example. -- `count` : **required** the number of workers to start using this config. -- `maxAttempts`: the maximum number of times to retry a job before giving up. Retries are handled with an exponential backoff in retry time, equal to the number of previous attempts \*\* 4. After this number, a job is considered "failed" and will not be re-attempted. Default: `24` -- `maxRuntime` : the maximum amount of time, in seconds, to try running a job before another worker will pick it up and try again. It's up to you to make sure your job doesn't run for longer than this amount of time! Default: 14,400 seconds (4 hours) -- `deleteFailedJobs` : when a job has failed (maximum number of retries has occured) you can keep the job in the database, or delete it. Default: `false` -- `deleteSuccessfulobs` : when a job has succeeded, you can keep the job in the database, or delete it. It's generally assumed that your jobs _will_ succeed so it usually makes sense to clear them out and keep the queue lean. Default: `true` -- `sleepDelay` : the amount of time, in seconds, to check the queue for another job to run. Too low and you'll be thrashing your storage system looking for jobs, too high and you start to have a long delay before any job is run. Default: `5` +- `count` : **[required]** the number of workers to start with this config. +- `maxAttempts`: the maximum number of times to retry a job before giving up. A job that throws an error will be set to retry in the future with an exponential backoff in time equal to the number of previous attempts \*\* 4. After this number, a job is considered "failed" and will not be re-attempted. Default: `24`. +- `maxRuntime` : the maximum amount of time, in seconds, to try running a job before another worker will pick it up and try again. It's up to you to make sure your job doesn't run for longer than this amount of time! Default: `14_400` (4 hours). +- `deleteFailedJobs` : when a job has failed (maximum number of retries has occured) you can keep the job in the database, or delete it. Default: `false`. +- `deleteSuccessfulobs` : when a job has succeeded, you can keep the job in the database, or delete it. It's generally assumed that your jobs _will_ succeed so it usually makes sense to clear them out and keep the queue lean. Default: `true`. +- `sleepDelay` : the amount of time, in seconds, to check the queue for another job to run. Too low and you'll be thrashing your storage system looking for jobs, too high and you start to have a long delay before any job is run. Default: `5`. See the next section for advanced usage examples, like multiple worker groups. ## Job Workers -A job worker actually executes your jobs. The workers will ask the adapter to find a job to work on. The adapter will mark the job as locked (the process name and a timestamp is set on the job) and then the worker will call `perform()` on your job, passing in any args that were given to `performLater()`. The behavior of what happens when the job succeeds or fails depends on the config options you set in the `JobManager`. By default, successful jobs are removed from storage and failed jobs and kept around so you can diagnose what happened. +A job worker actually executes your jobs. The workers will ask the adapter to find a job to work on. The adapter will mark the job as locked (the process name and a timestamp is set on the job) and then the worker will call `perform()` on your job, passing in any args that were given when you scheduled it. The behavior of what happens when the job succeeds or fails depends on the config options you set in the `JobManager`. By default, successful jobs are removed from storage and failed jobs and kept around so you can diagnose what happened. The runner has several modes it can start in depending on how you want it to behave. @@ -511,7 +535,7 @@ These modes are ideal when you're creating a job and want to be sure it runs cor yarn rw jobs work ``` -This process will stay attached the console and continually look for new jobs and execute them as they are found. The log level is set to `debug` by default so you'll see everything. Pressing Ctrl-C to cancel the process (sending SIGINT) will start a graceful shutdown: the workers will complete any work they're in the middle of before exiting. To cancel immediately, hit Ctrl-C again (or send SIGTERM) and they'll stop in the middle of what they're doing. Note that this could leave locked jobs in the database, but they will be picked back up again if a new worker starts with the same name as the one that locked the process. They'll also be picked up automatically after `maxRuntime` has expired, even if they are still locked. +This process will stay attached the console and continually look for new jobs and execute them as they are found. The log level is set to `debug` by default so you'll see everything. Pressing `Ctrl-C` to cancel the process (sending `SIGINT`) will start a graceful shutdown: the workers will complete any work they're in the middle of before exiting. To cancel immediately, hit `Ctrl-C` again (or send `SIGTERM`) and they'll stop in the middle of what they're doing. Note that this could leave locked jobs in the database, but they will be picked back up again if a new worker starts with the same name as the one that locked the process. They'll also be picked up automatically after `maxRuntime` has expired, even if they are still locked. :::caution Long running jobs @@ -545,15 +569,15 @@ In production you'll want your job workers running forever in the background. Fo yarn rw jobs start ``` -That will start a single worker, watching all queues, and then detatch it from the console. If you care about the output of that worker then you'll want to have configured a logger that writes to the filesystem or sends to a third party log aggregator. +That will start a number of workers determined by the `workers` config on the `JobManager` and then detatch them from the console. If you care about the output of that worker then you'll want to have configured a logger that writes to the filesystem or sends to a third party log aggregator. -To stop the worker: +To stop the workers: ```bash yarn rw jobs stop ``` -Or to restart on that's already running: +Or to restart any that are already running: ```bash yarn rw jobs restart @@ -561,7 +585,7 @@ yarn rw jobs restart ### Multiple Workers -With the default configuration options generated with the `yarn rw setup jobs` command, you'll have one worker group. If you simply want more workers that use the same `adapter` and `queue` settings, just increase the `count`: +With the default configuration options generated with the `yarn rw setup jobs` command you'll have one worker group. If you simply want more workers that use the same `adapter` and `queue` settings, increase the `count`: ```js export const jobs = new JobManager({ @@ -624,11 +648,21 @@ export const jobs = new JobManager({ }) ``` -Here, we have 2 workers working on the "default" queue and 1 worker looking at the "email" queue (which will only try a job once, only wait 30 seconds for it to finish, and delete the job if it fails). You can also have different worker groups using different adapters. For example, you may have store and work on some jobs in your database using the `PrismaAdapter` and some jobs/workers using a `RedisAdapter`. +Here, we have 2 workers working on the "default" queue and 1 worker looking at the "email" queue (which will only try a job once, wait 30 seconds for it to finish, and delete the job if it fails). You can also have different worker groups using different adapters. For example, you may have store and work on some jobs in your database using the `PrismaAdapter` and some jobs/workers using a `RedisAdapter`. :::info -We don't currently provide a `RedisAdapter` but plan to add one soon! +We don't currently provide a `RedisAdapter` but plan to add one soon! You'll want to create additional schedulers to use any other adapters as well: + +```js +export const prismaLater = jobs.createScheduler({ + adapter: 'prisma', +}) + +export const redisLater = jobs.createScheduler({ + adapter: 'redis', +}) +``` ::: @@ -660,24 +694,30 @@ Of course if you have a process monitor system watching your workers you'll to u As noted above, although the workers are started and detached using the `yarn rw jobs start` command, there is nothing to monitor those workers to make sure they keep running. To do that, you'll want to start the workers yourself (or have your process monitor start them) using command line flags. -You can do this with the `yarn rw-jobs-worker` command. To start a single worker, using the first `workers` config object, would run: +You can do this with the `yarn rw-jobs-worker` command. The flags passed to the script tell it which worker group config to use to start itself, and which `id` to give this worker (if you're running more than one). To start a single worker, using the first `workers` config object, you would run: ```bash yarn rw-jobs-worker --index=0 --id=0 ``` +:::info + +The job runner started with `yarn rw jobs start` runs this same command behind the scenes for you, keeping it attached or detatched depending on if you start in `work` or `start` mode! + +::: + ### Flags -- `--index` : a number that represents the index of the `workers` config option you passed to the `JobManager`. Setting this to `0`, for example, uses the first object in the array to set all config options for the worker. -- `--id` : a number identifier that's set as part of the process name. For example, starting a worker with `--id=0` and then inspecting your process list will show one running named `rw-job-worker.queue-name.0`. Using `yarn rw-jobs-worker` only ever starts a single instance, so if your config had a `count` of `2` you'd need to run the command twice, one with `--id=0` and a second time with `--id=1`. +- `--index` : a number that represents the index of the `workers` config array you passed to the `JobManager`. Setting this to `0`, for example, uses the first object in the array to set all config options for the worker. +- `--id` : a number identifier that's set as part of the process name. Starting a worker with `--id=0` and then inspecting your process list will show one running named `rw-job-worker.queue-name.0`. Using `yarn rw-jobs-worker` only ever starts a single instance, so if your config had a `count` of `2` you'd need to run the command twice, once with `--id=0` and a second time with `--id=1`. - `--workoff` : a boolean that will execute all currently available jobs and then cause the worker to exit. Defaults to `false` -- `--clear` : a boolean that will remove all jobs from the queue. Defaults to `false` +- `--clear` : a boolean that starts a worker to remove all jobs from all queues. Defaults to `false` -Your process monitor can now restart the processes automatically if they crash. +Your process monitor can now restart the workers automatically if they crash since the monitor using the worker script itself and not the wrapping job runner. ### What Happens if a Worker Crashes? -If a worker crashes because of circumstances outside of our control, the job will remained locked in the storage system since the worker couldn't finish work and clean it up itself. In this case, the job will be picked up again after `maxRuntime` has expired—a new worker will pick it up and re-lock using it's own indentification. +If a worker crashes because of circumstances outside of your control the job will remained locked in the storage system: the worker couldn't finish work and clean up after itself. When this happens, the job will be picked up again immediately if a new worker starts with the same process title, otherwise when `maxRuntime` has passed it's eliglbe for any worker to pick up and re-lock. ## Creating Your Own Adapter @@ -685,11 +725,11 @@ We'd love the community to contribue adapters for Redwood Job! Take a look at th The general gist of the required functions: -- `find()` should find a job to be run, lock it and return it (minimum return of an object containing `id`, `name`, `path`, and `args` properties) +- `find()` should find a job to be run, lock it and return it (minimum return of an object containing `id`, `name`, `path`, `args` and `attempts` properties) - `schedule()` accepts `name`, `path`, `args`, `runAt`, `queue` and `priority` and should store the job -- `success()` accepts the same job object returned from `find()` and the `deleteSuccessfulJobs` boolean Does whatever success means to you (remove the job from storage, perhaps) -- `error()` accepts the same job object returned from `find()` and an error instance. Does whatever failure means to you (unlock the job and reschedule a time for it to run again in the future, or give up if `maxAttempts` is reached) -- `failure()` is called when the job has reached `maxAttempts` and accepts the job object and the `deleteFailedJobs` boolean. +- `success()` accepts the same job object returned from `find()` and a `deleteJob` boolean for whether the job should be deleted upon success. +- `error()` accepts the same job object returned from `find()` and an error instance. Does whatever failure means to you (like unlock the job and reschedule a time for it to run again in the future) +- `failure()` is called when the job has reached `maxAttempts`. Accepts the job object and a `deleteJob` boolean that says whether the job should be deleted. - `clear()` remove all jobs from the queue (mostly used in development). ## The Future diff --git a/docs/static/img/background-jobs/jobs-queues.png b/docs/static/img/background-jobs/jobs-queues.png new file mode 100644 index 0000000000000000000000000000000000000000..b5ef2cf4a020b25f0405c37f4bb3909c7204f04a GIT binary patch literal 145259 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 literal 0 HcmV?d00001 diff --git a/docs/static/img/background-jobs/jobs-workers.png b/docs/static/img/background-jobs/jobs-workers.png new file mode 100644 index 0000000000000000000000000000000000000000..64e7d502f7764578477c377cf771a2f4fda54d89 GIT binary patch literal 231526 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 Date: Mon, 19 Aug 2024 17:33:19 -0700 Subject: [PATCH 236/258] Add comment about queues to setup template --- packages/cli/src/commands/setup/jobs/templates/jobs.ts.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template b/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template index fc7b73f78e5f..c79669655368 100644 --- a/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template +++ b/packages/cli/src/commands/setup/jobs/templates/jobs.ts.template @@ -17,7 +17,7 @@ export const jobs = new JobManager({ { adapter: 'prisma', logger, - queue: '*', + queue: '*', // watch all queues count: 1, maxAttempts: 24, maxRuntime: 14_400, From 421b9d9887908091668cfa8bba42cc52c0b859b6 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 19 Aug 2024 18:03:40 -0700 Subject: [PATCH 237/258] Use mockLogger from core mocks --- .../adapters/BaseAdapter/__tests__/BaseAdapter.test.ts | 9 +-------- .../PrismaAdapter/__tests__/PrismaAdapter.test.ts | 8 +------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/packages/jobs/src/adapters/BaseAdapter/__tests__/BaseAdapter.test.ts b/packages/jobs/src/adapters/BaseAdapter/__tests__/BaseAdapter.test.ts index 9d573b79fb10..2c3768378e5c 100644 --- a/packages/jobs/src/adapters/BaseAdapter/__tests__/BaseAdapter.test.ts +++ b/packages/jobs/src/adapters/BaseAdapter/__tests__/BaseAdapter.test.ts @@ -2,14 +2,7 @@ import { describe, expect, vi, it } from 'vitest' import { BaseAdapter } from '../BaseAdapter' import type { BaseAdapterOptions } from '../BaseAdapter' - -const mockLogger = { - log: vi.fn(() => {}), - info: vi.fn(() => {}), - debug: vi.fn(() => {}), - warn: vi.fn(() => {}), - error: vi.fn(() => {}), -} +import { mockLogger } from '../../../core/__tests__/mocks' interface TestAdapterOptions extends BaseAdapterOptions { foo: string diff --git a/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts b/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts index 3a478901349d..5648336314c6 100644 --- a/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts +++ b/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts @@ -2,6 +2,7 @@ import type { PrismaClient } from '@prisma/client' import { describe, expect, vi, it, beforeEach, afterEach } from 'vitest' import { DEFAULT_MODEL_NAME } from '../../../consts' +import { mockLogger } from '../../../core/__tests__/mocks' import * as errors from '../errors' import { PrismaAdapter } from '../PrismaAdapter' @@ -9,13 +10,6 @@ vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) let mockDb: PrismaClient -const mockLogger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -} - beforeEach(() => { mockDb = { _activeProvider: 'sqlite', From 192e6a5fc3b24f252ccbb1f0a0505b20f1abc48e Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 19 Aug 2024 18:07:26 -0700 Subject: [PATCH 238/258] Reorganize cases --- packages/jobs/src/bins/rw-jobs.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index 034fc8ee75b3..4f6b4a2c8526 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -71,9 +71,9 @@ const parseArgs = (argv: string[]) => { // The output would be: // // [ -// [0, 0], // first array, first worker -// [0, 1], // first array, second worker -// [1, 0], // second array, first worker +// [0, 0], // first array element, first worker +// [0, 1], // first array element, second worker +// [1, 0], // second array element, first worker // ] const buildNumWorkers = (config: any) => { const workers: NumWorkersConfig = [] @@ -269,6 +269,12 @@ const main = async () => { logger, }) return process.exit(0) + case 'stop': + return await stopWorkers({ + numWorkers, + signal: 'SIGINT', + logger, + }) case 'restart': await stopWorkers({ numWorkers, signal: 'SIGINT', logger }) startWorkers({ @@ -294,12 +300,6 @@ const main = async () => { }), logger, }) - case 'stop': - return await stopWorkers({ - numWorkers, - signal: 'SIGINT', - logger, - }) case 'clear': return clearQueue({ logger }) } From 0bee63192bb0b5dbed578a1cc28d58d80bd81dc6 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Mon, 19 Aug 2024 18:35:07 -0700 Subject: [PATCH 239/258] Adds tests for rw-jobs --- .../jobs/src/bins/__tests__/rw-jobs.test.js | 95 ++++++++++++++++++- packages/jobs/src/bins/rw-jobs.ts | 4 +- 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/packages/jobs/src/bins/__tests__/rw-jobs.test.js b/packages/jobs/src/bins/__tests__/rw-jobs.test.js index 32fe44e693ba..c056b4a1ba73 100644 --- a/packages/jobs/src/bins/__tests__/rw-jobs.test.js +++ b/packages/jobs/src/bins/__tests__/rw-jobs.test.js @@ -1,9 +1,94 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import path from 'node:path' -// import * as runner from '../runner' +import { buildNumWorkers, startWorkers } from '../rw-jobs' +import { mockLogger } from '../../core/__tests__/mocks' -describe('runner', () => { - it('placeholder', () => { - expect(true).toBeTruthy() +vi.mock('@redwoodjs/cli-helpers/loadEnvFiles', () => { + return { + loadEnvFiles: () => {}, + } +}) + +const mocks = vi.hoisted(() => { + return { + fork: vi.fn(), + } +}) + +vi.mock('node:child_process', () => { + return { + fork: mocks.fork, + } +}) + +describe('buildNumWorkers()', () => { + it('turns an array of counts config into an array of arrays', () => { + const config = [ + { + count: 2, + }, + { + count: 1, + }, + ] + + const result = buildNumWorkers(config) + + expect(result).toEqual([ + [0, 0], + [0, 1], + [1, 0], + ]) + }) +}) + +describe('startWorkers()', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('forks a single worker', () => { + const mockWorker = { + on: () => {}, + } + mocks.fork.mockImplementation(() => mockWorker) + + startWorkers({ numWorkers: [[0, 0]], logger: mockLogger }) + + expect(mocks.fork).toHaveBeenCalledWith( + expect.stringContaining('rw-jobs-worker.js'), + ['--index', '0', '--id', '0'], + expect.objectContaining({ + detached: false, + stdio: 'inherit', + }), + ) + }) + + it('forks multiple workers', () => { + const mockWorker = { + on: () => {}, + } + mocks.fork.mockImplementation(() => mockWorker) + + startWorkers({ + numWorkers: [ + [0, 0], + [0, 1], + ], + logger: mockLogger, + }) + + expect(mocks.fork).toHaveBeenCalledWith( + expect.stringContaining('rw-jobs-worker.js'), + ['--index', '0', '--id', '0'], + expect.any(Object), + ) + expect(mocks.fork).toHaveBeenCalledWith( + expect.stringContaining('rw-jobs-worker.js'), + ['--index', '0', '--id', '1'], + expect.any(Object), + ) }) }) diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index 4f6b4a2c8526..8b9fed2a1d1d 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -75,7 +75,7 @@ const parseArgs = (argv: string[]) => { // [0, 1], // first array element, second worker // [1, 0], // second array element, first worker // ] -const buildNumWorkers = (config: any) => { +export const buildNumWorkers = (config: any) => { const workers: NumWorkersConfig = [] config.map((worker: any, index: number) => { @@ -87,7 +87,7 @@ const buildNumWorkers = (config: any) => { return workers } -const startWorkers = ({ +export const startWorkers = ({ numWorkers, detach = false, workoff = false, From 0a1d82ce08478d64f58937b7e6e07f139efcef25 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Wed, 21 Aug 2024 00:12:04 +0100 Subject: [PATCH 240/258] use underline for link styling --- packages/cli-helpers/src/lib/colors.ts | 2 +- packages/cli/src/lib/colors.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli-helpers/src/lib/colors.ts b/packages/cli-helpers/src/lib/colors.ts index b584b75baca1..f2c32abafa9b 100644 --- a/packages/cli-helpers/src/lib/colors.ts +++ b/packages/cli-helpers/src/lib/colors.ts @@ -18,5 +18,5 @@ export const colors = { tip: chalk.green, important: chalk.magenta, caution: chalk.red, - link: chalk.hex('#e8e8e8'), + link: chalk.underline, } diff --git a/packages/cli/src/lib/colors.js b/packages/cli/src/lib/colors.js index 0bbed5ee01bb..1ff32be4224f 100644 --- a/packages/cli/src/lib/colors.js +++ b/packages/cli/src/lib/colors.js @@ -22,5 +22,5 @@ export default { tip: chalk.green, important: chalk.magenta, caution: chalk.red, - link: chalk.hex('#e8e8e8'), + link: chalk.underline, } From 5b1c7ea637bffa8b93a78682c0ea51c07c0207ff Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Wed, 21 Aug 2024 00:31:53 +0100 Subject: [PATCH 241/258] update deps and building --- packages/jobs/build.mts | 20 ++++++++++++-------- packages/jobs/package.json | 7 +++++-- packages/jobs/tsconfig.build.json | 12 ++++++++++++ packages/jobs/tsconfig.cjs.json | 2 +- packages/jobs/tsconfig.json | 14 ++++---------- yarn.lock | 1 + 6 files changed, 35 insertions(+), 21 deletions(-) create mode 100644 packages/jobs/tsconfig.build.json diff --git a/packages/jobs/build.mts b/packages/jobs/build.mts index 1b29b6313cd0..d107312c1aff 100644 --- a/packages/jobs/build.mts +++ b/packages/jobs/build.mts @@ -1,4 +1,9 @@ -import { build, defaultBuildOptions } from '@redwoodjs/framework-tools' +import { + build, + buildEsm, + defaultBuildOptions, + defaultIgnorePatterns, +} from '@redwoodjs/framework-tools' import { generateTypesCjs, generateTypesEsm, @@ -6,12 +11,7 @@ import { } from '@redwoodjs/framework-tools/generateTypes' // ESM build and type generation -await build({ - buildOptions: { - ...defaultBuildOptions, - format: 'esm', - }, -}) +await buildEsm() await generateTypesEsm() // CJS build, type generation, and package.json insert @@ -19,10 +19,14 @@ await build({ buildOptions: { ...defaultBuildOptions, outdir: 'dist/cjs', + tsconfig: 'tsconfig.cjs.json', + }, + entryPointOptions: { + // We don't need a CJS copy of the bins + ignore: [...defaultIgnorePatterns, './src/bins'], }, }) await generateTypesCjs() await insertCommonJsPackageJson({ buildFileUrl: import.meta.url, - cjsDir: 'dist/cjs', }) diff --git a/packages/jobs/package.json b/packages/jobs/package.json index a2ffaa127a7e..104ed4ad688a 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -33,7 +33,7 @@ "scripts": { "build": "tsx ./build.mts", "build:pack": "yarn pack -o redwoodjs-jobs.tgz", - "build:types": "tsc --build --verbose ./tsconfig.json", + "build:types": "tsc --build --verbose ./tsconfig.build.json", "build:types-cjs": "tsc --build --verbose ./tsconfig.cjs.json", "build:watch": "nodemon --watch src --ext \"js,jsx,ts,tsx\" --ignore dist --exec \"yarn build\"", "check:attw": "yarn rw-fwtools-attw", @@ -42,10 +42,13 @@ "test": "vitest run", "test:watch": "vitest" }, + "dependencies": { + "@redwoodjs/cli-helpers": "workspace:*", + "@redwoodjs/project-config": "workspace:*" + }, "devDependencies": { "@prisma/client": "5.18.0", "@redwoodjs/framework-tools": "workspace:*", - "@redwoodjs/project-config": "workspace:*", "concurrently": "8.2.2", "publint": "0.2.10", "tsx": "4.17.0", diff --git a/packages/jobs/tsconfig.build.json b/packages/jobs/tsconfig.build.json new file mode 100644 index 000000000000..b8547d222235 --- /dev/null +++ b/packages/jobs/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.compilerOption.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "moduleResolution": "Node16", + "module": "Node16", + "tsBuildInfoFile": "./tsconfig.build.tsbuildinfo" + }, + "include": ["src"], + "references": [{ "path": "../cli-helpers" }, { "path": "../project-config" }] +} diff --git a/packages/jobs/tsconfig.cjs.json b/packages/jobs/tsconfig.cjs.json index eaa211040f2f..a660cecf11ff 100644 --- a/packages/jobs/tsconfig.cjs.json +++ b/packages/jobs/tsconfig.cjs.json @@ -1,5 +1,5 @@ { - "extends": "./tsconfig.json", + "extends": "./tsconfig.build.json", "compilerOptions": { "outDir": "dist/cjs", "tsBuildInfoFile": "./tsconfig.cjs.tsbuildinfo" diff --git a/packages/jobs/tsconfig.json b/packages/jobs/tsconfig.json index 62e8ee9de229..799799136e51 100644 --- a/packages/jobs/tsconfig.json +++ b/packages/jobs/tsconfig.json @@ -1,16 +1,10 @@ { "extends": "../../tsconfig.compilerOption.json", "compilerOptions": { - "rootDir": "src", - "outDir": "dist", "moduleResolution": "Node16", - "module": "Node16", - "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + "module": "Node16" }, - "include": ["src/**/*"], - "references": [ - { "path": "../babel-config" }, - { "path": "../cli-helpers" }, - { "path": "../project-config" } - ] + "include": ["."], + "exclude": ["dist", "node_modules", "**/__mocks__", "**/__tests__/fixtures"], + "references": [{ "path": "../cli-helpers" }, { "path": "../project-config" }] } diff --git a/yarn.lock b/yarn.lock index 8f7cd63232c1..c5869a544c8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8331,6 +8331,7 @@ __metadata: resolution: "@redwoodjs/jobs@workspace:packages/jobs" dependencies: "@prisma/client": "npm:5.18.0" + "@redwoodjs/cli-helpers": "workspace:*" "@redwoodjs/framework-tools": "workspace:*" "@redwoodjs/project-config": "workspace:*" concurrently: "npm:8.2.2" From 13b2458b19ea633b3a911e49753587affc4d2445 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Wed, 21 Aug 2024 00:51:54 +0100 Subject: [PATCH 242/258] ensure tests are ts --- .../adapters/BaseAdapter/__tests__/BaseAdapter.test.ts | 8 ++++---- .../PrismaAdapter/__tests__/PrismaAdapter.test.ts | 8 ++++---- .../{rw-jobs-worker.test.js => rw-jobs-worker.test.ts} | 3 +-- .../__tests__/{rw-jobs.test.js => rw-jobs.test.ts} | 5 ++--- .../__tests__/{Executor.test.js => Executor.test.ts} | 8 ++++---- .../{JobManager.test.js => JobManager.test.ts} | 6 +++--- .../__tests__/{Scheduler.test.js => Scheduler.test.ts} | 6 +++--- .../core/__tests__/{Worker.test.js => Worker.test.ts} | 10 +++++----- 8 files changed, 26 insertions(+), 28 deletions(-) rename packages/jobs/src/bins/__tests__/{rw-jobs-worker.test.js => rw-jobs-worker.test.ts} (77%) rename packages/jobs/src/bins/__tests__/{rw-jobs.test.js => rw-jobs.test.ts} (92%) rename packages/jobs/src/core/__tests__/{Executor.test.js => Executor.test.ts} (95%) rename packages/jobs/src/core/__tests__/{JobManager.test.js => JobManager.test.ts} (95%) rename packages/jobs/src/core/__tests__/{Scheduler.test.js => Scheduler.test.ts} (97%) rename packages/jobs/src/core/__tests__/{Worker.test.js => Worker.test.ts} (97%) diff --git a/packages/jobs/src/adapters/BaseAdapter/__tests__/BaseAdapter.test.ts b/packages/jobs/src/adapters/BaseAdapter/__tests__/BaseAdapter.test.ts index 2c3768378e5c..ef122900f3d7 100644 --- a/packages/jobs/src/adapters/BaseAdapter/__tests__/BaseAdapter.test.ts +++ b/packages/jobs/src/adapters/BaseAdapter/__tests__/BaseAdapter.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, vi, it } from 'vitest' +import { describe, expect, it } from 'vitest' -import { BaseAdapter } from '../BaseAdapter' -import type { BaseAdapterOptions } from '../BaseAdapter' -import { mockLogger } from '../../../core/__tests__/mocks' +import { mockLogger } from '../../../core/__tests__/mocks.js' +import { BaseAdapter } from '../BaseAdapter.js' +import type { BaseAdapterOptions } from '../BaseAdapter.js' interface TestAdapterOptions extends BaseAdapterOptions { foo: string diff --git a/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts b/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts index 5648336314c6..0a623214fa9c 100644 --- a/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts +++ b/packages/jobs/src/adapters/PrismaAdapter/__tests__/PrismaAdapter.test.ts @@ -1,10 +1,10 @@ import type { PrismaClient } from '@prisma/client' import { describe, expect, vi, it, beforeEach, afterEach } from 'vitest' -import { DEFAULT_MODEL_NAME } from '../../../consts' -import { mockLogger } from '../../../core/__tests__/mocks' -import * as errors from '../errors' -import { PrismaAdapter } from '../PrismaAdapter' +import { DEFAULT_MODEL_NAME } from '../../../consts.js' +import { mockLogger } from '../../../core/__tests__/mocks.js' +import * as errors from '../errors.js' +import { PrismaAdapter } from '../PrismaAdapter.js' vi.useFakeTimers().setSystemTime(new Date('2024-01-01')) diff --git a/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js b/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.ts similarity index 77% rename from packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js rename to packages/jobs/src/bins/__tests__/rw-jobs-worker.test.ts index 607f02c63085..22640a07f84d 100644 --- a/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.js +++ b/packages/jobs/src/bins/__tests__/rw-jobs-worker.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest' -// import * as worker from '../worker' - describe('worker', () => { + // TODO: Consider adding tests it('placeholder', () => { expect(true).toBeTruthy() }) diff --git a/packages/jobs/src/bins/__tests__/rw-jobs.test.js b/packages/jobs/src/bins/__tests__/rw-jobs.test.ts similarity index 92% rename from packages/jobs/src/bins/__tests__/rw-jobs.test.js rename to packages/jobs/src/bins/__tests__/rw-jobs.test.ts index c056b4a1ba73..d7f0670fa08e 100644 --- a/packages/jobs/src/bins/__tests__/rw-jobs.test.js +++ b/packages/jobs/src/bins/__tests__/rw-jobs.test.ts @@ -1,8 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import path from 'node:path' -import { buildNumWorkers, startWorkers } from '../rw-jobs' -import { mockLogger } from '../../core/__tests__/mocks' +import { mockLogger } from '../../core/__tests__/mocks.js' +import { buildNumWorkers, startWorkers } from '../rw-jobs.js' vi.mock('@redwoodjs/cli-helpers/loadEnvFiles', () => { return { diff --git a/packages/jobs/src/core/__tests__/Executor.test.js b/packages/jobs/src/core/__tests__/Executor.test.ts similarity index 95% rename from packages/jobs/src/core/__tests__/Executor.test.js rename to packages/jobs/src/core/__tests__/Executor.test.ts index 8e114e5dba31..9afe28c2cfce 100644 --- a/packages/jobs/src/core/__tests__/Executor.test.js +++ b/packages/jobs/src/core/__tests__/Executor.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, vi, it } from 'vitest' -import { DEFAULT_LOGGER } from '../../consts' -import * as errors from '../../errors' -import { Executor } from '../Executor' +import { DEFAULT_LOGGER } from '../../consts.js' +import * as errors from '../../errors.js' +import { Executor } from '../Executor.js' -import { mockLogger } from './mocks' +import { mockLogger } from './mocks.js' const mocks = vi.hoisted(() => { return { diff --git a/packages/jobs/src/core/__tests__/JobManager.test.js b/packages/jobs/src/core/__tests__/JobManager.test.ts similarity index 95% rename from packages/jobs/src/core/__tests__/JobManager.test.js rename to packages/jobs/src/core/__tests__/JobManager.test.ts index 5d60d95942d9..5a83c1f8af80 100644 --- a/packages/jobs/src/core/__tests__/JobManager.test.js +++ b/packages/jobs/src/core/__tests__/JobManager.test.ts @@ -1,9 +1,9 @@ import { describe, expect, vi, it, beforeEach } from 'vitest' -import { JobManager } from '../JobManager' -import { Scheduler } from '../Scheduler' +import { JobManager } from '../JobManager.js' +import { Scheduler } from '../Scheduler.js' -import { mockAdapter, mockLogger } from './mocks' +import { mockAdapter, mockLogger } from './mocks.js' vi.mock('../Scheduler') diff --git a/packages/jobs/src/core/__tests__/Scheduler.test.js b/packages/jobs/src/core/__tests__/Scheduler.test.ts similarity index 97% rename from packages/jobs/src/core/__tests__/Scheduler.test.js rename to packages/jobs/src/core/__tests__/Scheduler.test.ts index 6513306c70c4..d87736868171 100644 --- a/packages/jobs/src/core/__tests__/Scheduler.test.js +++ b/packages/jobs/src/core/__tests__/Scheduler.test.ts @@ -1,9 +1,9 @@ import { describe, expect, vi, it, beforeEach } from 'vitest' -import * as errors from '../../errors' -import { Scheduler } from '../Scheduler' +import * as errors from '../../errors.js' +import { Scheduler } from '../Scheduler.js' -import { mockAdapter, mockLogger } from './mocks' +import { mockAdapter, mockLogger } from './mocks.js' vi.useFakeTimers() diff --git a/packages/jobs/src/core/__tests__/Worker.test.js b/packages/jobs/src/core/__tests__/Worker.test.ts similarity index 97% rename from packages/jobs/src/core/__tests__/Worker.test.js rename to packages/jobs/src/core/__tests__/Worker.test.ts index c2ecdc6966a2..3acc747d677f 100644 --- a/packages/jobs/src/core/__tests__/Worker.test.js +++ b/packages/jobs/src/core/__tests__/Worker.test.ts @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, vi, it } from 'vitest' -import { DEFAULT_LOGGER } from '../../consts' -import * as errors from '../../errors' -import { Executor } from '../Executor' -import { Worker } from '../Worker' +import { DEFAULT_LOGGER } from '../../consts.js' +import * as errors from '../../errors.js' +import { Executor } from '../Executor.js' +import { Worker } from '../Worker.js' -import { mockLogger } from './mocks' +import { mockLogger } from './mocks.js' // don't execute any code inside Executor, just spy on whether functions are // called From d958e5fab3932ba9d1ce6eefa6a31eca982ec286 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Wed, 21 Aug 2024 01:18:35 +0100 Subject: [PATCH 243/258] log heap usage --- packages/jobs/vitest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/jobs/vitest.config.ts b/packages/jobs/vitest.config.ts index 139a0882cc51..68ca131fd8cb 100644 --- a/packages/jobs/vitest.config.ts +++ b/packages/jobs/vitest.config.ts @@ -4,5 +4,6 @@ export default defineConfig({ test: { testTimeout: 15_000, exclude: [...configDefaults.exclude, '**/fixtures', '**/dist'], + logHeapUsage: true, }, }) From 446ea5ddffacd27b7ec5ff32913c4147dc9da164 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Wed, 21 Aug 2024 01:18:50 +0100 Subject: [PATCH 244/258] update util mocks --- packages/jobs/src/core/__tests__/mocks.ts | 35 ++++++++++++++++------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/jobs/src/core/__tests__/mocks.ts b/packages/jobs/src/core/__tests__/mocks.ts index c35eecbcd4f5..bcc915cd2a40 100644 --- a/packages/jobs/src/core/__tests__/mocks.ts +++ b/packages/jobs/src/core/__tests__/mocks.ts @@ -1,20 +1,33 @@ import { vi } from 'vitest' -export const mockLogger = { - log: vi.fn(() => {}), +import type { + ErrorOptions, + FailureOptions, + FindArgs, + SchedulePayload, + SuccessOptions, +} from '../../adapters/BaseAdapter/BaseAdapter.js' +import { BaseAdapter } from '../../adapters/BaseAdapter/BaseAdapter.js' +import type { BasicLogger, PossibleBaseJob } from '../../types.js' + +export const mockLogger: BasicLogger = { info: vi.fn(() => {}), debug: vi.fn(() => {}), warn: vi.fn(() => {}), error: vi.fn(() => {}), } -export const mockAdapter = { - options: {}, - logger: mockLogger, - schedule: vi.fn(() => {}), - find: () => null, - clear: () => {}, - success: (_options) => {}, - error: (_options) => {}, - failure: (_options) => {}, +export class MockAdapter extends BaseAdapter { + constructor() { + super({ + logger: mockLogger, + }) + } + + schedule = vi.fn((_payload: SchedulePayload): void => {}) + find = vi.fn((_args: FindArgs): PossibleBaseJob => undefined) + success = vi.fn((_options: SuccessOptions): void => {}) + error = vi.fn((_options: ErrorOptions): void => {}) + failure = vi.fn((_options: FailureOptions): void => {}) + clear = vi.fn((): void => {}) } From 45b866364bef718f9823c615951bccfb60069668 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Wed, 21 Aug 2024 01:20:44 +0100 Subject: [PATCH 245/258] update worker test --- .../jobs/src/core/__tests__/Worker.test.ts | 163 +++++++++++++----- 1 file changed, 122 insertions(+), 41 deletions(-) diff --git a/packages/jobs/src/core/__tests__/Worker.test.ts b/packages/jobs/src/core/__tests__/Worker.test.ts index 3acc747d677f..bd05aed25c57 100644 --- a/packages/jobs/src/core/__tests__/Worker.test.ts +++ b/packages/jobs/src/core/__tests__/Worker.test.ts @@ -5,7 +5,7 @@ import * as errors from '../../errors.js' import { Executor } from '../Executor.js' import { Worker } from '../Worker.js' -import { mockLogger } from './mocks.js' +import { mockLogger, MockAdapter } from './mocks.js' // don't execute any code inside Executor, just spy on whether functions are // called @@ -13,24 +13,35 @@ vi.mock('../Executor') describe('constructor', () => { it('saves options', () => { - const options = { adapter: 'adapter', logger: mockLogger, queues: ['*'] } + const options = { + adapter: new MockAdapter(), + logger: mockLogger, + queues: ['*'], + processName: 'mockProcessName', + } const worker = new Worker(options) expect(worker.options.adapter).toEqual(options.adapter) }) it('extracts adapter from options to variable', () => { - const options = { adapter: 'adapter', logger: mockLogger, queues: ['*'] } + const options = { + adapter: new MockAdapter(), + logger: mockLogger, + queues: ['*'], + processName: 'mockProcessName', + } const worker = new Worker(options) - expect(worker.adapter).toEqual('adapter') + expect(worker.adapter).toEqual(options.adapter) }) it('extracts logger from options to variable', () => { const options = { - adapter: 'adapter', + adapter: new MockAdapter(), logger: mockLogger, queues: ['*'], + processName: 'mockProcessName', } const worker = new Worker(options) @@ -38,7 +49,11 @@ describe('constructor', () => { }) it('defaults logger if not provided', () => { - const options = { adapter: 'adapter', queues: ['*'] } + const options = { + adapter: new MockAdapter(), + queues: ['*'], + processName: 'mockProcessName', + } const worker = new Worker(options) expect(worker.logger).toEqual(DEFAULT_LOGGER) @@ -46,21 +61,22 @@ describe('constructor', () => { it('extracts processName from options to variable', () => { const options = { - adapter: 'adapter', + adapter: new MockAdapter(), logger: mockLogger, queues: ['*'], - processName: 'processName', + processName: 'mockProcessName', } const worker = new Worker(options) - expect(worker.processName).toEqual('processName') + expect(worker.processName).toEqual('mockProcessName') }) it('extracts queue from options to variable', () => { const options = { - adapter: 'adapter', + adapter: new MockAdapter(), logger: mockLogger, queues: ['default'], + processName: 'mockProcessName', } const worker = new Worker(options) @@ -69,9 +85,10 @@ describe('constructor', () => { it('extracts clear from options to variable', () => { const options = { - adapter: 'adapter', + adapter: new MockAdapter(), logger: mockLogger, queues: ['*'], + processName: 'mockProcessName', clear: true, } const worker = new Worker(options) @@ -80,7 +97,12 @@ describe('constructor', () => { }) it('defaults clear if not provided', () => { - const options = { adapter: 'adapter', logger: mockLogger, queues: ['*'] } + const options = { + adapter: new MockAdapter(), + logger: mockLogger, + queues: ['*'], + processName: 'mockProcessName', + } const worker = new Worker(options) expect(worker.clear).toEqual(false) @@ -88,9 +110,10 @@ describe('constructor', () => { it('extracts maxAttempts from options to variable', () => { const options = { - adapter: 'adapter', + adapter: new MockAdapter(), logger: mockLogger, queues: ['*'], + processName: 'mockProcessName', maxAttempts: 10, } const worker = new Worker(options) @@ -100,9 +123,10 @@ describe('constructor', () => { it('extracts maxRuntime from options to variable', () => { const options = { - adapter: 'adapter', + adapter: new MockAdapter(), logger: mockLogger, queues: ['*'], + processName: 'mockProcessName', maxRuntime: 10, } const worker = new Worker(options) @@ -112,21 +136,23 @@ describe('constructor', () => { it('extracts deleteFailedJobs from options to variable', () => { const options = { - adapter: 'adapter', + adapter: new MockAdapter(), logger: mockLogger, queues: ['*'], - deleteFailedJobs: 10, + processName: 'mockProcessName', + deleteFailedJobs: true, } const worker = new Worker(options) - expect(worker.deleteFailedJobs).toEqual(10) + expect(worker.deleteFailedJobs).toEqual(true) }) it('extracts sleepDelay from options to variable', () => { const options = { - adapter: 'adapter', + adapter: new MockAdapter(), logger: mockLogger, queues: ['*'], + processName: 'mockProcessName', sleepDelay: 5, } const worker = new Worker(options) @@ -136,9 +162,10 @@ describe('constructor', () => { it('can set sleepDelay to 0', () => { const options = { - adapter: 'adapter', + adapter: new MockAdapter(), logger: mockLogger, queues: ['*'], + processName: 'mockProcessName', sleepDelay: 0, } const worker = new Worker(options) @@ -147,7 +174,12 @@ describe('constructor', () => { }) it('sets forever', () => { - const options = { adapter: 'adapter', logger: mockLogger, queues: ['*'] } + const options = { + adapter: new MockAdapter(), + logger: mockLogger, + queues: ['*'], + processName: 'mockProcessName', + } const worker = new Worker(options) expect(worker.forever).toEqual(true) @@ -155,9 +187,10 @@ describe('constructor', () => { it('extracts workoff from options to variable', () => { const options = { - adapter: 'adapter', + adapter: new MockAdapter(), logger: mockLogger, queues: ['*'], + processName: 'mockProcessName', workoff: true, } const worker = new Worker(options) @@ -166,29 +199,49 @@ describe('constructor', () => { }) it('defaults workoff if not provided', () => { - const options = { adapter: 'adapter', logger: mockLogger, queues: ['*'] } + const options = { + adapter: new MockAdapter(), + logger: mockLogger, + processName: 'mockProcessName', + queues: ['*'], + } const worker = new Worker(options) expect(worker.workoff).toEqual(false) }) it('sets lastCheckTime to the current time', () => { - const options = { adapter: 'adapter', logger: mockLogger, queues: ['*'] } + const options = { + adapter: new MockAdapter(), + logger: mockLogger, + processName: 'mockProcessName', + queues: ['*'], + } const worker = new Worker(options) expect(worker.lastCheckTime).toBeInstanceOf(Date) }) it('throws an error if adapter not set', () => { + // @ts-expect-error testing error case expect(() => new Worker()).toThrow(errors.AdapterRequiredError) }) it('throws an error if queues not set', () => { - expect(() => new Worker()).toThrow(errors.AdapterRequiredError) + const options = { + adapter: new MockAdapter(), + } + // @ts-expect-error testing error case + expect(() => new Worker(options)).toThrow(errors.QueuesRequiredError) }) it('throws an error if queues is an empty array', () => { - expect(() => new Worker()).toThrow(errors.AdapterRequiredError) + const options = { + adapter: new MockAdapter(), + queues: [], + } + // @ts-expect-error testing error case + expect(() => new Worker(options)).toThrow(errors.QueuesRequiredError) }) }) @@ -198,10 +251,11 @@ describe('run', async () => { }) it('tries to find a job', async () => { - const adapter = { find: vi.fn(() => null) } + const adapter = new MockAdapter() const worker = new Worker({ adapter, logger: mockLogger, + processName: 'mockProcessName', queues: ['*'], sleepDelay: 0, forever: false, @@ -217,10 +271,11 @@ describe('run', async () => { }) it('will try to find jobs in a loop until `forever` is set to `false`', async () => { - const adapter = { find: vi.fn(() => null) } + const adapter = new MockAdapter() const worker = new Worker({ adapter, logger: mockLogger, + processName: 'mockProcessName', queues: ['*'], sleepDelay: 0.01, forever: true, @@ -234,12 +289,12 @@ describe('run', async () => { }) it('does nothing if no job found and forever=false', async () => { - const adapter = { find: vi.fn(() => null) } - vi.spyOn(Executor, 'constructor') + const adapter = new MockAdapter() const worker = new Worker({ adapter, logger: mockLogger, + processName: 'mockProcessName', queues: ['*'], sleepDelay: 0, forever: false, @@ -250,12 +305,12 @@ describe('run', async () => { }) it('exits if no job found and workoff=true', async () => { - const adapter = { find: vi.fn(() => null) } - vi.spyOn(Executor, 'constructor') + const adapter = new MockAdapter() const worker = new Worker({ adapter, logger: mockLogger, + processName: 'mockProcessName', queues: ['*'], sleepDelay: 0, workoff: true, @@ -266,17 +321,21 @@ describe('run', async () => { }) it('loops until no job found when workoff=true', async () => { - const adapter = { - find: vi - .fn() - .mockImplementationOnce(() => ({ id: 1 })) - .mockImplementationOnce(() => null), - } - //vi.spyOn(Executor, 'constructor') + const adapter = new MockAdapter() + adapter.find + .mockImplementationOnce(() => ({ + id: 1, + name: 'mockJobName', + path: 'mockJobPath', + args: [], + attempts: 0, + })) + .mockImplementationOnce(() => undefined) const worker = new Worker({ adapter, logger: mockLogger, + processName: 'mockProcessName', queues: ['*'], sleepDelay: 0, workoff: true, @@ -287,10 +346,18 @@ describe('run', async () => { }) it('initializes an Executor instance if the job is found', async () => { - const adapter = { find: vi.fn(() => ({ id: 1 })) } + const adapter = new MockAdapter() + adapter.find.mockImplementationOnce(() => ({ + id: 1, + name: 'mockJobName', + path: 'mockJobPath', + args: [], + attempts: 0, + })) const worker = new Worker({ adapter, logger: mockLogger, + processName: 'mockProcessName', queues: ['*'], sleepDelay: 0, forever: false, @@ -303,7 +370,13 @@ describe('run', async () => { expect(Executor).toHaveBeenCalledWith({ adapter, - job: { id: 1 }, + job: { + id: 1, + name: 'mockJobName', + path: 'mockJobPath', + args: [], + attempts: 0, + }, logger: worker.logger, maxAttempts: 10, deleteSuccessfulJobs: false, @@ -312,10 +385,18 @@ describe('run', async () => { }) it('calls `perform` on the Executor instance', async () => { - const adapter = { find: vi.fn(() => ({ id: 1 })) } + const adapter = new MockAdapter() + adapter.find.mockImplementationOnce(() => ({ + id: 1, + name: 'mockJobName', + path: 'mockJobPath', + args: [], + attempts: 0, + })) const worker = new Worker({ adapter, logger: mockLogger, + processName: 'mockProcessName', queues: ['*'], sleepDelay: 0, forever: false, From 6ae9f5ff64fdbd82cb318e3c4a262a7ac5210e07 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Wed, 21 Aug 2024 01:32:15 +0100 Subject: [PATCH 246/258] update executor test --- .../jobs/src/core/__tests__/Executor.test.ts | 127 +++++++++++------- 1 file changed, 76 insertions(+), 51 deletions(-) diff --git a/packages/jobs/src/core/__tests__/Executor.test.ts b/packages/jobs/src/core/__tests__/Executor.test.ts index 9afe28c2cfce..fb09b82b9dfb 100644 --- a/packages/jobs/src/core/__tests__/Executor.test.ts +++ b/packages/jobs/src/core/__tests__/Executor.test.ts @@ -2,9 +2,10 @@ import { beforeEach, describe, expect, vi, it } from 'vitest' import { DEFAULT_LOGGER } from '../../consts.js' import * as errors from '../../errors.js' +import type { BaseJob } from '../../types.js' import { Executor } from '../Executor.js' -import { mockLogger } from './mocks.js' +import { MockAdapter, mockLogger } from './mocks.js' const mocks = vi.hoisted(() => { return { @@ -19,50 +20,65 @@ vi.mock('../../loaders', () => { }) describe('constructor', () => { + const mockAdapter = new MockAdapter() + const mockJob: BaseJob = { + id: 1, + name: 'mockJob', + path: 'mockJob/mockJob', + args: [], + attempts: 0, + } + it('saves options', () => { - const options = { adapter: 'adapter', job: 'job' } - const exector = new Executor(options) + const options = { adapter: mockAdapter, job: mockJob } + const executor = new Executor(options) - expect(exector.options).toEqual(expect.objectContaining(options)) + expect(executor.options).toEqual(expect.objectContaining(options)) }) it('extracts adapter from options to variable', () => { - const options = { adapter: 'adapter', job: 'job' } - const exector = new Executor(options) + const options = { adapter: mockAdapter, job: mockJob } + const executor = new Executor(options) - expect(exector.adapter).toEqual('adapter') + expect(executor.adapter).toEqual(mockAdapter) }) it('extracts job from options to variable', () => { - const options = { adapter: 'adapter', job: 'job' } - const exector = new Executor(options) + const options = { adapter: mockAdapter, job: mockJob } + const executor = new Executor(options) - expect(exector.job).toEqual('job') + expect(executor.job).toEqual(mockJob) }) it('extracts logger from options to variable', () => { - const options = { adapter: 'adapter', job: 'job', logger: { foo: 'bar' } } - const exector = new Executor(options) + const options = { + adapter: mockAdapter, + job: mockJob, + logger: mockLogger, + } + const executor = new Executor(options) - expect(exector.logger).toEqual({ foo: 'bar' }) + expect(executor.logger).toEqual(mockLogger) }) it('defaults logger if not provided', () => { - const options = { adapter: 'adapter', job: 'job' } - const exector = new Executor(options) + const options = { adapter: mockAdapter, job: mockJob } + const executor = new Executor(options) - expect(exector.logger).toEqual(DEFAULT_LOGGER) + expect(executor.logger).toEqual(DEFAULT_LOGGER) }) it('throws AdapterRequiredError if adapter is not provided', () => { - const options = { job: 'job' } + const options = { job: mockJob } + // @ts-expect-error testing error case expect(() => new Executor(options)).toThrow(errors.AdapterRequiredError) }) it('throws JobRequiredError if job is not provided', () => { - const options = { adapter: 'adapter' } + const options = { adapter: mockAdapter } + // @ts-expect-error testing error case expect(() => new Executor(options)).toThrow(errors.JobRequiredError) }) }) @@ -73,45 +89,56 @@ describe('perform', () => { }) it('invokes the `perform` method on the job class', async () => { - const mockAdapter = { success: vi.fn() } + const mockAdapter = new MockAdapter() + const mockJob = { + id: 1, + name: 'TestJob', + path: 'TestJob/TestJob', + args: ['foo'], + attempts: 0, + + perform: vi.fn(), + } + const options = { adapter: mockAdapter, logger: mockLogger, - job: { id: 1, name: 'TestJob', path: 'TestJob/TestJob', args: ['foo'] }, + job: mockJob, } const executor = new Executor(options) - const job = { id: 1 } - // mock the job - const mockJob = { perform: vi.fn() } - // spy on the perform method - const performSpy = vi.spyOn(mockJob, 'perform') // mock the `loadJob` loader to return the job mock mocks.loadJob.mockImplementation(() => mockJob) - await executor.perform(job) + await executor.perform() - expect(performSpy).toHaveBeenCalledWith('foo') + expect(mockJob.perform).toHaveBeenCalledWith('foo') }) it('invokes the `success` method on the adapter when job successful', async () => { - const mockAdapter = { success: vi.fn() } + const mockAdapter = new MockAdapter() + const mockJob = { + id: 1, + name: 'TestJob', + path: 'TestJob/TestJob', + args: ['foo'], + attempts: 0, + + perform: vi.fn(), + } const options = { adapter: mockAdapter, logger: mockLogger, - job: { id: 1, name: 'TestJob', path: 'TestJob/TestJob', args: ['foo'] }, + job: mockJob, } const executor = new Executor(options) - const job = { id: 1 } - // mock the job - const mockJob = { perform: vi.fn() } // spy on the success function of the adapter const adapterSpy = vi.spyOn(mockAdapter, 'success') // mock the `loadJob` loader to return the job mock mocks.loadJob.mockImplementation(() => mockJob) - await executor.perform(job) + await executor.perform() expect(adapterSpy).toHaveBeenCalledWith({ job: options.job, @@ -120,38 +147,36 @@ describe('perform', () => { }) it('invokes the `failure` method on the adapter when job fails', async () => { - const mockAdapter = { error: vi.fn() } + const mockAdapter = new MockAdapter() + const mockError = new Error('mock error in the job perform method') + const mockJob = { + id: 1, + name: 'TestJob', + path: 'TestJob/TestJob', + args: ['foo'], + attempts: 0, + + perform: vi.fn(() => { + throw mockError + }), + } const options = { adapter: mockAdapter, logger: mockLogger, - job: { - id: 1, - name: 'TestJob', - path: 'TestJob/TestJob', - args: ['foo'], - attempts: 0, - }, + job: mockJob, } const executor = new Executor(options) - const job = { id: 1 } - const error = new Error() - // mock the job - const mockJob = { - perform: vi.fn(() => { - throw error - }), - } // spy on the success function of the adapter const adapterSpy = vi.spyOn(mockAdapter, 'error') // mock the `loadJob` loader to return the job mock mocks.loadJob.mockImplementation(() => mockJob) - await executor.perform(job) + await executor.perform() expect(adapterSpy).toHaveBeenCalledWith({ job: options.job, - error, + error: mockError, }) }) }) From 1a5b91b7ec99421978054c75e80b22e2217c7499 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Wed, 21 Aug 2024 01:41:45 +0100 Subject: [PATCH 247/258] update job manager test --- .../src/core/__tests__/JobManager.test.ts | 56 ++++++++++++------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/packages/jobs/src/core/__tests__/JobManager.test.ts b/packages/jobs/src/core/__tests__/JobManager.test.ts index 5a83c1f8af80..6cfbd407324d 100644 --- a/packages/jobs/src/core/__tests__/JobManager.test.ts +++ b/packages/jobs/src/core/__tests__/JobManager.test.ts @@ -1,30 +1,33 @@ import { describe, expect, vi, it, beforeEach } from 'vitest' +import type { Job, JobDefinition } from '../../types.js' import { JobManager } from '../JobManager.js' import { Scheduler } from '../Scheduler.js' -import { mockAdapter, mockLogger } from './mocks.js' +import { MockAdapter, mockLogger } from './mocks.js' vi.mock('../Scheduler') describe('constructor', () => { - let manager, workers + const mockAdapter = new MockAdapter() + const adapters = { mock: mockAdapter } + const queues = ['queue'] + const logger = mockLogger + const workers = [ + { + adapter: 'mock' as const, + queue: '*', + count: 1, + }, + ] + + let manager: JobManager beforeEach(() => { - workers = [ - { - adapter: 'mock', - queue: '*', - count: 1, - }, - ] - manager = new JobManager({ - adapters: { - mock: mockAdapter, - }, - queues: ['queue'], - logger: mockLogger, + adapters, + queues, + logger, workers, }) }) @@ -34,11 +37,11 @@ describe('constructor', () => { }) it('saves queues', () => { - expect(manager.queues).toEqual(['queue']) + expect(manager.queues).toEqual(queues) }) it('saves logger', () => { - expect(manager.logger).toEqual(mockLogger) + expect(manager.logger).toEqual(logger) }) it('saves workers', () => { @@ -51,6 +54,8 @@ describe('createScheduler()', () => { vi.resetAllMocks() }) + const mockAdapter = new MockAdapter() + it('returns a function', () => { const manager = new JobManager({ adapters: { @@ -103,11 +108,17 @@ describe('createScheduler()', () => { adapters: { mock: mockAdapter, }, - queues: [], + queues: ['default'], logger: mockLogger, workers: [], }) - const mockJob = { perform: () => {} } + const mockJob: Job = { + queue: 'default', + name: 'mockJob', + path: 'mockJob/mockJob', + + perform: vi.fn(), + } const mockArgs = ['foo'] const mockOptions = { wait: 300 } const scheduler = manager.createScheduler({ adapter: 'mock' }) @@ -126,11 +137,14 @@ describe('createJob()', () => { it('returns the same job description that was passed in', () => { const manager = new JobManager({ adapters: {}, - queues: [], + queues: ['default'], logger: mockLogger, workers: [], }) - const jobDefinition = { perform: () => {} } + const jobDefinition: JobDefinition = { + queue: 'default', + perform: vi.fn(), + } const job = manager.createJob(jobDefinition) From 26e51d091fe26a7101bb3b8447d29888f7e7d139 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Wed, 21 Aug 2024 01:51:11 +0100 Subject: [PATCH 248/258] update schedular test --- .../jobs/src/core/__tests__/Scheduler.test.ts | 77 +++++++++++++++---- 1 file changed, 60 insertions(+), 17 deletions(-) diff --git a/packages/jobs/src/core/__tests__/Scheduler.test.ts b/packages/jobs/src/core/__tests__/Scheduler.test.ts index d87736868171..03b01dc92ae7 100644 --- a/packages/jobs/src/core/__tests__/Scheduler.test.ts +++ b/packages/jobs/src/core/__tests__/Scheduler.test.ts @@ -1,13 +1,20 @@ import { describe, expect, vi, it, beforeEach } from 'vitest' +import { + DEFAULT_PRIORITY, + DEFAULT_WAIT, + DEFAULT_WAIT_UNTIL, +} from '../../consts.js' import * as errors from '../../errors.js' import { Scheduler } from '../Scheduler.js' -import { mockAdapter, mockLogger } from './mocks.js' +import { MockAdapter, mockLogger } from './mocks.js' vi.useFakeTimers() describe('constructor', () => { + const mockAdapter = new MockAdapter() + it('saves adapter', () => { const scheduler = new Scheduler({ adapter: mockAdapter, @@ -28,6 +35,8 @@ describe('constructor', () => { }) describe('computeRunAt()', () => { + const mockAdapter = new MockAdapter() + it('returns a Date `wait` seconds in the future if `wait` set', () => { const scheduler = new Scheduler({ adapter: mockAdapter, @@ -35,9 +44,9 @@ describe('computeRunAt()', () => { }) const wait = 10 - expect(scheduler.computeRunAt({ wait })).toEqual( - new Date(Date.now() + wait * 1000), - ) + expect( + scheduler.computeRunAt({ wait, waitUntil: DEFAULT_WAIT_UNTIL }), + ).toEqual(new Date(Date.now() + wait * 1000)) }) it('returns the `waitUntil` Date, if set', () => { @@ -47,7 +56,9 @@ describe('computeRunAt()', () => { }) const waitUntil = new Date(2030, 0, 1, 12, 34, 56) - expect(scheduler.computeRunAt({ waitUntil })).toEqual(waitUntil) + expect(scheduler.computeRunAt({ wait: DEFAULT_WAIT, waitUntil })).toEqual( + waitUntil, + ) }) it('falls back to now', () => { @@ -56,26 +67,34 @@ describe('computeRunAt()', () => { logger: mockLogger, }) - expect(scheduler.computeRunAt({ wait: 0 })).toEqual(new Date()) - expect(scheduler.computeRunAt({ waitUntil: null })).toEqual(new Date()) + expect( + scheduler.computeRunAt({ wait: 0, waitUntil: DEFAULT_WAIT_UNTIL }), + ).toEqual(new Date()) + expect( + scheduler.computeRunAt({ wait: DEFAULT_WAIT, waitUntil: null }), + ).toEqual(new Date()) }) }) describe('buildPayload()', () => { + const mockAdapter = new MockAdapter() + it('returns a payload object', () => { const scheduler = new Scheduler({ adapter: mockAdapter, logger: mockLogger, }) const job = { + id: 1, name: 'JobName', path: 'JobPath/JobPath', queue: 'default', - priority: 25, + priority: 25 as const, + + perform: vi.fn(), } const args = [{ foo: 'bar' }] - const options = { priority: 25 } - const payload = scheduler.buildPayload(job, args, options) + const payload = scheduler.buildPayload(job, args) expect(payload.name).toEqual(job.name) expect(payload.path).toEqual(job.path) @@ -91,14 +110,16 @@ describe('buildPayload()', () => { logger: mockLogger, }) const job = { + id: 1, name: 'JobName', path: 'JobPath/JobPath', queue: 'default', - priority: 25, + + perform: vi.fn(), } const payload = scheduler.buildPayload(job) - expect(payload.priority).toEqual(job.priority) + expect(payload.priority).toEqual(DEFAULT_PRIORITY) }) it('takes into account a `wait` time', () => { @@ -107,10 +128,13 @@ describe('buildPayload()', () => { logger: mockLogger, }) const job = { + id: 1, name: 'JobName', path: 'JobPath/JobPath', queue: 'default', - priority: 25, + priority: 25 as const, + + perform: vi.fn(), } const options = { wait: 10 } const payload = scheduler.buildPayload(job, [], options) @@ -124,10 +148,13 @@ describe('buildPayload()', () => { logger: mockLogger, }) const job = { + id: 1, name: 'JobName', path: 'JobPath/JobPath', queue: 'default', - priority: 25, + priority: 25 as const, + + perform: vi.fn(), } const options = { waitUntil: new Date(2030, 0, 1, 12, 34, 56) } const payload = scheduler.buildPayload(job, [], options) @@ -141,11 +168,15 @@ describe('buildPayload()', () => { logger: mockLogger, }) const job = { + id: 1, name: 'JobName', path: 'JobPath/JobPath', - priority: 25, + priority: 25 as const, + + perform: vi.fn(), } + // @ts-expect-error testing error case expect(() => scheduler.buildPayload(job)).toThrow( errors.QueueNotDefinedError, ) @@ -153,6 +184,8 @@ describe('buildPayload()', () => { }) describe('schedule()', () => { + const mockAdapter = new MockAdapter() + beforeEach(() => { vi.resetAllMocks() }) @@ -163,12 +196,17 @@ describe('schedule()', () => { logger: mockLogger, }) const job = { + id: 1, name: 'JobName', path: 'JobPath/JobPath', queue: 'default', + + perform: vi.fn(), } const args = [{ foo: 'bar' }] - const options = {} + const options = { + wait: 10, + } await scheduler.schedule({ job, jobArgs: args, jobOptions: options }) @@ -190,12 +228,17 @@ describe('schedule()', () => { logger: mockLogger, }) const job = { + id: 1, name: 'JobName', path: 'JobPath/JobPath', queue: 'default', + + perform: vi.fn(), } const args = [{ foo: 'bar' }] - const options = {} + const options = { + wait: 10, + } await expect( scheduler.schedule({ job, jobArgs: args, jobOptions: options }), From f44fe04a053d7f14f4772f49c69597143acc4a67 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Wed, 21 Aug 2024 02:00:18 +0100 Subject: [PATCH 249/258] revert cli-helpers change --- packages/cli-helpers/src/lib/loadEnvFiles.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli-helpers/src/lib/loadEnvFiles.ts b/packages/cli-helpers/src/lib/loadEnvFiles.ts index 86646f34ddda..43b8dac821a5 100644 --- a/packages/cli-helpers/src/lib/loadEnvFiles.ts +++ b/packages/cli-helpers/src/lib/loadEnvFiles.ts @@ -17,10 +17,8 @@ export function loadEnvFiles() { loadDefaultEnvFiles(base) loadNodeEnvDerivedEnvFile(base) - // TODO(@jgmw): what the hell is going on here? - // @ts-expect-error Used to be Parser.default but that blows up when starting // job worker coordinator - const { loadEnvFiles } = Parser(hideBin(process.argv), { + const { loadEnvFiles } = Parser.default(hideBin(process.argv), { array: ['load-env-files'], default: { loadEnvFiles: [], From b19cffff64606016aeb0335902acabadda8fabf7 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Wed, 21 Aug 2024 02:43:35 +0100 Subject: [PATCH 250/258] misc docs updates --- docs/docs/background-jobs.md | 47 ++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/docs/docs/background-jobs.md b/docs/docs/background-jobs.md index 39f68be820ac..6b0ed6e1da49 100644 --- a/docs/docs/background-jobs.md +++ b/docs/docs/background-jobs.md @@ -6,11 +6,11 @@ No one likes waiting in line. This is especially true of your website: users don A typical create-user flow could look something like this: -![image](/img/background-jobs/jobs-before.png) +![jobs-before](/img/background-jobs/jobs-before.png) -If we want the email to be send asynchonously, we can shuttle that process off into a **background job**: +If we want the email to be send asynchronously, we can shuttle that process off into a **background job**: -![image](/img/background-jobs/jobs-after.png) +![jobs-after](/img/background-jobs/jobs-after.png) The user's response is returned much quicker, and the email is sent by another process, literally running in the background. All of the logic around sending the email is packaged up as a **job** and a **job worker** is responsible for executing it. @@ -36,7 +36,7 @@ There are three components to the background job system in Redwood: :::info Job execution time is never guaranteed -When scheduling a job, you're really saying "this is the earliest possible time I want this job to run": based on what other jobs are in the queue, and how busy the workers are, they may not get a chance to execute this one particiular job for an indeterminate amount of time. +When scheduling a job, you're really saying "this is the earliest possible time I want this job to run": based on what other jobs are in the queue, and how busy the workers are, they may not get a chance to execute this one particular job for an indeterminate amount of time. The only thing that's guaranteed is that a job won't run any _earlier_ than the time you specify. @@ -44,15 +44,15 @@ The only thing that's guaranteed is that a job won't run any _earlier_ than the ### Queues -Jobs are organized by a named **queue**. This is simply a string and has no special significance, other than letting you group jobs. Why group them? So that you can potentially have workers with different configruations working on them. Let's say you send a lot of emails, and you find that among all your other jobs, emails are starting to be noticeably delayed when sending. You can start assigning those jobs to the "email" queue and create a new worker group that _only_ focuses on jobs in that queue so that they're sent in a more timely manner. +Jobs are organized by a named **queue**. This is simply a string and has no special significance, other than letting you group jobs. Why group them? So that you can potentially have workers with different configurations working on them. Let's say you send a lot of emails, and you find that among all your other jobs, emails are starting to be noticeably delayed when sending. You can start assigning those jobs to the "email" queue and create a new worker group that _only_ focuses on jobs in that queue so that they're sent in a more timely manner. Jobs are sorted by **priority** before being selected to be worked on. Lower numbers mean higher priority: -![image](/img/background-jobs/jobs-queues.png) +![job-queues](/img/background-jobs/jobs-queues.png) You can also increase the number of workers in a group. If we bumped the group working on the "default" queue to 2 and started our new "email" group with 1 worker, once those workers started we would see them working on the following jobs: -![image](/img/background-jobs/jobs-workers.png) +![job-workers](/img/background-jobs/jobs-workers.png) ## Quick Start @@ -85,8 +85,8 @@ export const SampleJob = jobs.createJob({ // highlight-start perform: async (userId) => { jobs.logger.info(`Received user id ${userId}`) - // highlight-end }, + // highlight-end }) ``` @@ -101,7 +101,7 @@ import { later } from 'src/lib/jobs' import { SampleJob } from 'src/jobs/SampleJob' // highlight-end -export const createUser = ({ input }) => { +export const createUser = async ({ input }) => { const user = await db.user.create({ data: input }) // highlight-next-line await later(SampleJob, [user.id], { wait: 60 }) @@ -209,6 +209,7 @@ Jobs are defined as a plain object and given to the `createJob()` function (whic ```js import { db } from 'src/lib/db' +import { mailer } from 'src/lib/mailer' import { jobs } from 'src/lib/jobs' export const SendWelcomeEmailJob = jobs.createJob({ @@ -225,7 +226,7 @@ export const SendWelcomeEmailJob = jobs.createJob({ At a minimum, a job must contain the name of the `queue` the job should be saved to, and a function named `perform()` which contains the logic for your job. You can add additional properties to the object to support the task your job is performing, but `perform()` is what's invoked by the job worker that we'll see later. -Note that `perform()` can take any argument(s)s you want (or none at all), but it's a best practice to keep them as simple as possible. With the `PrismaAdapter` the arguments are stored in the database, so the list of arguments must be serializable to and from a string of JSON. +Note that `perform()` can take any argument(s) you want (or none at all), but it's a best practice to keep them as simple as possible. With the `PrismaAdapter` the arguments are stored in the database, so the list of arguments must be serializable to and from a string of JSON. :::info Keeping Arguments Simple @@ -270,7 +271,7 @@ later(SendWelcomeEmailJob, [user.id], { wait: 300 }) Or run it at a specific datetime: ```js -later(MilleniumAnnouncementJob, [user.id], { +later(MillenniumAnnouncementJob, [user.id], { waitUntil: new Date(3000, 0, 1, 0, 0, 0), }) ``` @@ -403,7 +404,7 @@ The object passed here contains all of the configuration for the Background Job #### `adapters` -This is the list of adapters that are available to handle storing and retreiving your jobs to and from the storage system. You could list more than one adapter here and then have multiple schedulers. Most folks will probably stick with a single one. +This is the list of adapters that are available to handle storing and retrieving your jobs to and from the storage system. You could list more than one adapter here and then have multiple schedulers. Most folks will probably stick with a single one. #### `queues` @@ -447,7 +448,7 @@ export const later = jobs.createScheduler({ }) ``` -- `adapter` : **[required]** the name of the adapter this scheudler will use to schedule jobs. Must be one of the keys that you gave to the `adapters` option on the JobManager itself. +- `adapter` : **[required]** the name of the adapter this scheduler will use to schedule jobs. Must be one of the keys that you gave to the `adapters` option on the JobManager itself. - `logger` : the logger to use for this instance of the scheduler. If not provided, defaults to the `logger` set on the `JobManager`. #### Scheduling Options @@ -482,7 +483,7 @@ export const SendWelcomeEmailJob = jobs.createJob({ ``` - `queue` : **[required]** the name of the queue that this job will be placed in. Must be one of the strings you assigned to `queues` array when you set up the `JobManager`. -- `priority` : within a queue you can have jobs that are more or less important. The workers will pull jobs off the queue with a higher priority before working on ones with a lower priority. A lower number is _higher_ in priority than a lower number. ie. the workers will work on a job with a priority of `1` before they work on one with a priority of `100`. If you don't override it here, the default priority is `50`. +- `priority` : within a queue you can have jobs that are more or less important. The workers will pull jobs off the queue with a higher priority before working on ones with a lower priority. A lower number is _higher_ in priority than a higher number. ie. the workers will work on a job with a priority of `1` before they work on one with a priority of `100`. If you don't override it here, the default priority is `50`. ### Worker Config @@ -515,8 +516,8 @@ This is an array of objects. Each object represents the config for a single "gro - `count` : **[required]** the number of workers to start with this config. - `maxAttempts`: the maximum number of times to retry a job before giving up. A job that throws an error will be set to retry in the future with an exponential backoff in time equal to the number of previous attempts \*\* 4. After this number, a job is considered "failed" and will not be re-attempted. Default: `24`. - `maxRuntime` : the maximum amount of time, in seconds, to try running a job before another worker will pick it up and try again. It's up to you to make sure your job doesn't run for longer than this amount of time! Default: `14_400` (4 hours). -- `deleteFailedJobs` : when a job has failed (maximum number of retries has occured) you can keep the job in the database, or delete it. Default: `false`. -- `deleteSuccessfulobs` : when a job has succeeded, you can keep the job in the database, or delete it. It's generally assumed that your jobs _will_ succeed so it usually makes sense to clear them out and keep the queue lean. Default: `true`. +- `deleteFailedJobs` : when a job has failed (maximum number of retries has occurred) you can keep the job in the database, or delete it. Default: `false`. +- `deleteSuccessfulJobs` : when a job has succeeded, you can keep the job in the database, or delete it. It's generally assumed that your jobs _will_ succeed so it usually makes sense to clear them out and keep the queue lean. Default: `true`. - `sleepDelay` : the amount of time, in seconds, to check the queue for another job to run. Too low and you'll be thrashing your storage system looking for jobs, too high and you start to have a long delay before any job is run. Default: `5`. See the next section for advanced usage examples, like multiple worker groups. @@ -545,7 +546,7 @@ The only way to guarantee a job will completely stop no matter what is for your ::: -To work on whatever outstanding jobs there are and then automaticaly exit use the `workoff` mode: +To work on whatever outstanding jobs there are and then automatically exit use the `workoff` mode: ```bash yarn rw jobs workoff @@ -569,7 +570,7 @@ In production you'll want your job workers running forever in the background. Fo yarn rw jobs start ``` -That will start a number of workers determined by the `workers` config on the `JobManager` and then detatch them from the console. If you care about the output of that worker then you'll want to have configured a logger that writes to the filesystem or sends to a third party log aggregator. +That will start a number of workers determined by the `workers` config on the `JobManager` and then detach them from the console. If you care about the output of that worker then you'll want to have configured a logger that writes to the filesystem or sends to a third party log aggregator. To stop the workers: @@ -674,7 +675,7 @@ For many use cases you may simply be able to rely on the job runner to start you yarn rw jobs start ``` -When you deploy new code you'll want to restart your runners to make sure they get the latest soruce files: +When you deploy new code you'll want to restart your runners to make sure they get the latest source files: ```bash yarn rw jobs restart @@ -702,7 +703,7 @@ yarn rw-jobs-worker --index=0 --id=0 :::info -The job runner started with `yarn rw jobs start` runs this same command behind the scenes for you, keeping it attached or detatched depending on if you start in `work` or `start` mode! +The job runner started with `yarn rw jobs start` runs this same command behind the scenes for you, keeping it attached or detached depending on if you start in `work` or `start` mode! ::: @@ -717,11 +718,11 @@ Your process monitor can now restart the workers automatically if they crash sin ### What Happens if a Worker Crashes? -If a worker crashes because of circumstances outside of your control the job will remained locked in the storage system: the worker couldn't finish work and clean up after itself. When this happens, the job will be picked up again immediately if a new worker starts with the same process title, otherwise when `maxRuntime` has passed it's eliglbe for any worker to pick up and re-lock. +If a worker crashes because of circumstances outside of your control the job will remained locked in the storage system: the worker couldn't finish work and clean up after itself. When this happens, the job will be picked up again immediately if a new worker starts with the same process title, otherwise when `maxRuntime` has passed it's eligible for any worker to pick up and re-lock. ## Creating Your Own Adapter -We'd love the community to contribue adapters for Redwood Job! Take a look at the source for `BaseAdapter` for what's absolutely required, and then the source for `PrismaAdapter` to see a concrete implementation. +We'd love the community to contribute adapters for Redwood Job! Take a look at the source for `BaseAdapter` for what's absolutely required, and then the source for `PrismaAdapter` to see a concrete implementation. The general gist of the required functions: @@ -740,4 +741,4 @@ There's still more to add to background jobs! Our current TODO list: - RW Studio integration: monitor the state of your outstanding jobs - Baremetal integration: if jobs are enabled, monitor the workers with pm2 - Recurring jobs -- Livecycle hooks: `beforePerform()`, `afterPerform()`, `afterSuccess()`, `afterFailure()` +- Lifecycle hooks: `beforePerform()`, `afterPerform()`, `afterSuccess()`, `afterFailure()` From e89363faaf332f43150d09d092d5ec5fdd7b596e Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Wed, 21 Aug 2024 02:43:51 +0100 Subject: [PATCH 251/258] fix __dirname and cjs typegen issue --- packages/jobs/src/bins/rw-jobs.ts | 16 ++++++++++------ packages/jobs/tsconfig.cjs.json | 10 +++++++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index 8b9fed2a1d1d..2876172791b6 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -111,11 +111,15 @@ export const startWorkers = ({ } // fork the worker process - const worker = fork(path.join(__dirname, 'rw-jobs-worker.js'), workerArgs, { - detached: detach, - stdio: detach ? 'ignore' : 'inherit', - env: process.env, - }) + const worker = fork( + path.join(import.meta.dirname, 'rw-jobs-worker.js'), + workerArgs, + { + detached: detach, + stdio: detach ? 'ignore' : 'inherit', + env: process.env, + }, + ) if (detach) { worker.unref() @@ -162,7 +166,7 @@ const stopWorkers = async ({ const clearQueue = ({ logger }: { logger: BasicLogger }) => { logger.warn(`Starting worker to clear job queue...`) - fork(path.join(__dirname, 'rw-jobs-worker.js'), ['--clear']) + fork(path.join(import.meta.dirname, 'rw-jobs-worker.js'), ['--clear']) } const signalSetup = ({ diff --git a/packages/jobs/tsconfig.cjs.json b/packages/jobs/tsconfig.cjs.json index a660cecf11ff..6c80953a45d7 100644 --- a/packages/jobs/tsconfig.cjs.json +++ b/packages/jobs/tsconfig.cjs.json @@ -3,5 +3,13 @@ "compilerOptions": { "outDir": "dist/cjs", "tsBuildInfoFile": "./tsconfig.cjs.tsbuildinfo" - } + }, + "exclude": [ + "dist", + "node_modules", + "**/__tests__", + "**/__mocks__", + "**/*.test.*", + "./src/bins/**/*" + ] } From f5a579ca2b8d4597d55c43cee2064da134e6f911 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 20 Aug 2024 15:39:09 -0700 Subject: [PATCH 252/258] Rewrite docs for type --- packages/jobs/src/types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/jobs/src/types.ts b/packages/jobs/src/types.ts index a6c53fe19467..9493a9d85ba1 100644 --- a/packages/jobs/src/types.ts +++ b/packages/jobs/src/types.ts @@ -140,9 +140,9 @@ export interface JobDefinition< TArgs extends unknown[] = [], > { /** - * The name of the queue that this job should always be scheduled on. This defaults - * to the queue that the scheduler was created with, but can be overridden when - * scheduling a job. + * The name of the queue that this job should always be scheduled on. This + * must be one of the values in the `queues` array when you created the + * `JobManager`. */ queue: TQueues[number] From b48bf1cbae025bd175ad3546201d682a8720208a Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 20 Aug 2024 19:01:02 -0700 Subject: [PATCH 253/258] Add demandCommand() to jobs cli --- packages/cli/src/commands/jobs.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli/src/commands/jobs.js b/packages/cli/src/commands/jobs.js index b7e15face14f..e0a7d4159f43 100644 --- a/packages/cli/src/commands/jobs.js +++ b/packages/cli/src/commands/jobs.js @@ -6,9 +6,7 @@ export const builder = (yargs) => { // Disable yargs parsing of commands and options because it's forwarded // to rw-jobs yargs - .strictOptions(false) - .strictCommands(false) - .strict(false) + .demandCommand(1) .parserConfiguration({ 'camel-case-expansion': false, }) From fbf7e511ccc3656133bc8f172436669ff1ed10ab Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Wed, 21 Aug 2024 03:05:00 +0100 Subject: [PATCH 254/258] remove stray comment --- packages/cli-helpers/src/lib/loadEnvFiles.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli-helpers/src/lib/loadEnvFiles.ts b/packages/cli-helpers/src/lib/loadEnvFiles.ts index 43b8dac821a5..95f70afd8d39 100644 --- a/packages/cli-helpers/src/lib/loadEnvFiles.ts +++ b/packages/cli-helpers/src/lib/loadEnvFiles.ts @@ -17,7 +17,6 @@ export function loadEnvFiles() { loadDefaultEnvFiles(base) loadNodeEnvDerivedEnvFile(base) - // job worker coordinator const { loadEnvFiles } = Parser.default(hideBin(process.argv), { array: ['load-env-files'], default: { From bdb0807b86fb786b6f6d8b38ef555195bc8cb985 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 20 Aug 2024 19:10:13 -0700 Subject: [PATCH 255/258] Back to strict options --- packages/cli/src/commands/jobs.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/jobs.js b/packages/cli/src/commands/jobs.js index e0a7d4159f43..b7e15face14f 100644 --- a/packages/cli/src/commands/jobs.js +++ b/packages/cli/src/commands/jobs.js @@ -6,7 +6,9 @@ export const builder = (yargs) => { // Disable yargs parsing of commands and options because it's forwarded // to rw-jobs yargs - .demandCommand(1) + .strictOptions(false) + .strictCommands(false) + .strict(false) .parserConfiguration({ 'camel-case-expansion': false, }) From e13464d70a131ec2b95ce24ec97e6ffd60d38713 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 20 Aug 2024 19:39:03 -0700 Subject: [PATCH 256/258] Check for first command so that starting with `yarn rw jobs` and `yarn rw-jobs` behaves the same --- packages/jobs/src/bins/rw-jobs.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index 2876172791b6..f29881b4d247 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -26,7 +26,13 @@ loadEnvFiles() process.title = 'rw-jobs' const parseArgs = (argv: string[]) => { - const parsed: Record = yargs(hideBin(argv)) + const commandString = hideBin(argv) + + if (commandString.length === 1 && commandString[0] === 'jobs') { + commandString.shift() + } + + const parsed: Record = yargs(commandString) .usage( 'Starts the RedwoodJob runner to process background jobs\n\nUsage: $0 [options]', ) From 1d48740f28801cf67b84a91f7403046e12cdb532 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 20 Aug 2024 19:48:54 -0700 Subject: [PATCH 257/258] Replace command name in rw-jobs so help output matches actual yarn command --- packages/jobs/src/bins/rw-jobs.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs.ts b/packages/jobs/src/bins/rw-jobs.ts index f29881b4d247..6f19327b05f8 100755 --- a/packages/jobs/src/bins/rw-jobs.ts +++ b/packages/jobs/src/bins/rw-jobs.ts @@ -34,7 +34,7 @@ const parseArgs = (argv: string[]) => { const parsed: Record = yargs(commandString) .usage( - 'Starts the RedwoodJob runner to process background jobs\n\nUsage: $0 [options]', + 'Starts the RedwoodJob runner to process background jobs\n\nUsage: rw jobs [options]', ) .command('work', 'Start a worker and process jobs') .command('workoff', 'Start a worker and exit after all jobs processed') @@ -44,14 +44,20 @@ const parseArgs = (argv: string[]) => { .command('clear', 'Clear the job queue') .demandCommand(1, 'You must specify a mode to start in') .example( - '$0 work', + 'rw jobs work', 'Start the job workers using the job config and work on jobs until manually stopped', ) .example( - '$0 start', + 'rw jobs start', 'Start the job workers using the job config and detach, running in daemon mode', ) - .help().argv + .help() + .parse(commandString, (_err: any, _argv: any, output: any) => { + if (output) { + const newOutput = output.replaceAll('rw-jobs.js', 'rw jobs') + console.log(newOutput) + } + }) return { command: parsed._[0] } } From 401a8d589a7d27ef7333ae95f7247882a4c302d6 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Tue, 20 Aug 2024 19:49:51 -0700 Subject: [PATCH 258/258] Make --index and --id required --- packages/jobs/src/bins/rw-jobs-worker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/jobs/src/bins/rw-jobs-worker.ts b/packages/jobs/src/bins/rw-jobs-worker.ts index 1f1fca717036..c2354da174ef 100755 --- a/packages/jobs/src/bins/rw-jobs-worker.ts +++ b/packages/jobs/src/bins/rw-jobs-worker.ts @@ -26,15 +26,15 @@ const parseArgs = (argv: string[]) => { ) .option('index', { type: 'number', + required: true, description: 'The index of the `workers` array from the exported `jobs` config to use to configure this worker', - default: 0, }) .option('id', { type: 'number', + required: true, description: 'The worker count id to identify this worker. ie: if you had `count: 2` in your worker config, you would have two workers with ids 0 and 1', - default: 0, }) .option('workoff', { type: 'boolean',