Skip to content

Job Scheduling & Background Tasks

Grant uses the Adapter Pattern for background jobs — a single IJobAdapter interface with swappable providers. Use node-cron for development and BullMQ for production.

Architecture

Provider Comparison

node-cronBullMQ
Best forDevelopment, single instanceProduction, multi-instance
External depsNoneRedis
Job persistenceNo (lost on restart)Yes (stored in Redis)
Distributed lockingNoYes (prevents duplicate runs)
RetriesNoYes (exponential backoff)
MonitoringLogs onlyBuilt-in job tracking

Configuration

VariableDefaultDescription
JOBS_ENABLEDtrueEnable/disable job scheduling
JOBS_PROVIDERnode-cronnode-cron or bullmq
JOBS_DATA_RETENTION_SCHEDULE0 2 * * *Cron pattern for data cleanup

BullMQ-specific (requires CACHE_STRATEGY=redis):

VariableDefaultDescription
JOBS_BULLMQ_ATTEMPTS3Retry attempts for failed jobs
JOBS_BULLMQ_BACKOFF_TYPEexponentialexponential or fixed
JOBS_BULLMQ_BACKOFF_DELAY2000Backoff delay in ms

Interface

typescript
interface IJobAdapter {
  schedule(job: ScheduledJob, handler: JobHandler): Promise<void>;
  cancel(jobId: string): Promise<void>;
  isScheduled(jobId: string): Promise<boolean>;
  getScheduledJobs(): Promise<ScheduledJob[]>;
  trigger(jobId: string): Promise<JobResult>;
  shutdown(): Promise<void>;
}

Jobs receive an AppContext (repositories + services + system user) — background jobs run without a request/user context.

Creating a Job

  1. Create a file in apps/api/src/jobs/:
typescript
// apps/api/src/jobs/my-job.job.ts
import { config } from '@/config';
import type { AppContext } from '@/lib/app-context';
import type { JobExecutionContext, JobHandler, JobResult, ScheduledJob } from '@/lib/jobs';
import { jobRegistry } from '@/lib/jobs/job-registry';
import { createModuleLogger } from '@/lib/logger';

const logger = createModuleLogger('MyJob');

function createHandler(): JobHandler {
  return async (context: JobExecutionContext & { appContext?: AppContext }): Promise<JobResult> => {
    const appContext = context.appContext;
    if (!appContext) return { success: false, message: 'No app context' };

    const result = await appContext.services.myService.doWork();
    logger.info({ jobId: context.jobId, result }, 'Job completed');
    return { success: true, data: result };
  };
}

const job: ScheduledJob = {
  id: 'my-job',
  name: 'My Background Job',
  schedule: '0 3 * * *', // Daily at 3 AM
  enabled: true,
};

jobRegistry.register({ job, handler: createHandler() });
  1. Import in apps/api/src/jobs/index.ts:
typescript
import './my-job.job';

Jobs auto-register when imported. At startup, initializeJobs() discovers all registered jobs and schedules them with the configured adapter.

Built-in Job: Data Retention Cleanup

The platform ships with a data retention cleanup job that runs daily at 2 AM (configurable). It permanently deletes soft-deleted accounts and backups that have exceeded their retention period.

Tenant Context

Background jobs fall into two categories:

TypeTenant contextExample
Scheduled (cron)Not needed — platform-wideData retention, system maintenance
Enqueued (one-off)Required — pass scope from authProject export, send notification

For tenant-scoped enqueued jobs, always validate the scope:

typescript
import { assertTenantActive, validateTenantJobContext } from '@/lib/jobs';

async execute(context: JobExecutionContext): Promise<JobResult> {
  validateTenantJobContext(context, true);                    // structural check
  await assertTenantActive(context.scope!, this.appContext.db); // DB check
  // ... tenant-scoped work
}

Cron Patterns

PatternDescription
0 2 * * *Daily at 2:00 AM
0 */6 * * *Every 6 hours
0 0 * * 0Weekly on Sunday at midnight
*/15 * * * *Every 15 minutes

See crontab.guru for interactive pattern building.


Related:

Released under the MIT License.