@reflet/cron
TypeScript icon, indicating that this package has built-in type declarations

1.3.1 • Public • Published

@reflet/cron 🌠

The best decorators for node-cron. Have a look at Reflet's philosophy.

Getting started

  1. Make sure you have decorators enabled. (click for details)

    • Enable them in your TypeScript compiler options.

      "experimentalDecorators": true,
      "emitDecoratorMetadata": true,
    • Install reflect-metadata shim.

      yarn add reflect-metadata
    • Import the shim in your program before everything else.

      import 'reflect-metadata'
  2. Install the package along with peer dependencies.

    yarn add @reflet/cron cron && yarn add -D @types/cron @types/node
  3. Create cron jobs.

    // jobs.ts
    import { Cron, Expression } from '@reflet/cron'
    
    @Cron.TimeZone('Europe/Paris')
    export class Jobs {
      @Cron(Expression.EVERY_SECOND)
      async logMessage() {
        console.log('You will see this message every second');
      }
    
      @Cron(Expression.EVERY_DAY_AT_MIDNIGHT)
      async sendEmailToAdmin() {
        await emailClient.send({ to: 'admin@github.com', content: 'This is fine' })
      }
    }
  4. Initialize and start cron jobs.

    // main.ts
    import { initCronJobs } from '@reflet/cron'
    import { Jobs } from './jobs.ts'
    
    const jobs = initCronJobs(Jobs)
    jobs.startAll()

Basic Job

🔦 @Cron(time)
🔦 initCronJobs(class)
💫 Related node-cron constructor: CronJob

import { Cron, initCronJobs } from '@reflet/cron'

class Jobs {
  @Cron('*/1 * * * *')
  logFoo() {
    console.log('foo')
  }
}

const jobs = initCronJobs(Jobs) // returns a Map of your jobs, with the method names as keys.

// You can start jobs individually
jobs.get('logFoo').start()

// Or all at once
jobs.startAll()

// Same with stop
jobs.stopAll()

💡 Use Expression enum to avoid using cron syntax:

import { Cron, Expression } from '@reflet/cron'

class Jobs {
  @Cron(Expression.EVERY_MINUTE)
  logFoo() {
    console.log('foo')
  }
}

Init encapsulation

Wrap initCronJobs into a static method to encapsulate initialization as well:

import { initCronJobs, Cron } from '@reflet/cron'

class Jobs {
  static init(...deps: ConstructorParameters<typeof Jobs>) {
    return initCronJobs(new Jobs(...deps))
  }

  @Cron(Expression.EVERY_MINUTE)
  logFoo() {
    console.log('foo')
  }
}

const jobs = Jobs.init()

💡 As a convenience, you can inherit from the abstract class Initializer which already have the init static method:

import { Initializer } from '@reflet/cron'

// You must pass the child class as type parameter to properly infer its constructor parameters.
class Jobs extends Initializer<typeof Jobs> {
  @Cron(Expression.EVERY_MINUTE)
  logFoo() {
    console.log('foo')
  }
}

const jobs = Jobs.init()

Extra properties

Compared to node-cron, Reflet adds 2 properties to jobs' instances:

  • firing: readonly boolean that helps us determine if the job is actually being executed. Because the original node-cron running property defines whether the job has been started or stopped. This property is used by the PreventOverlap decorator.
  • name: string in the following format: class.method, to help distinguish jobs.

Common options

🔦 @Cron.Start, @Cron.RunOnInit, @Cron.OnComplete(fn), @Cron.TimeZone(tz), @Cron.UtcOffset(offset), @Cron.UnrefTimeout
💫 Related node-cron parameters: start, runOnInit, onComplete, timezone, utcOffset, unrefTimeout

  • @Cron.Start: Starts the job after init (does not immediately fire its function). Can be used with or without invokation.
  • @Cron.RunOnInit: Fire the job's function on init. Can be used with or without invokation.
  • @Cron.OnComplete(fn): A function that will fire when the job is stopped with job.stop().
  • @Cron.TimeZone(tz): Specify the timezone for the execution. Type is narrowed to an union of all available timezones.
  • @Cron.UtcOffset(offset): Specify the offset of the timezone instead of the timezone directly. Type is narrowed to an union of all available offsets (plus number).

Please refer to the node-cron repository for more details.

import { Cron, Expression } from '@reflet/cron'

class Jobs {
  @Cron.Start 
  @Cron.RunOnInit
  @Cron.TimeZone('Europe/Paris')
  @Cron(Expression.EVERY_SECOND)
  doSomething() {}

  @Cron.Start()
  @Cron.RunOnInit()
  @Cron.UtcOffset('+01:00')
  @Cron.OnComplete(() => {}) 
  @Cron(Expression.EVERY_SECOND)
  doSomethingElse() {}
}

Errors and retries

Errors happening in a cron job are automatically logged to stderr instead of crashing the server.

Catch

🔦 @Cron.Catch(errorHandler)

This decorator allows you to do something else than logging with your errors.

import { Cron, Expression } from '@reflet/cron'

class Jobs {
  @Cron.Catch(async (err) => {
    console.error(err)
    await db.insert(err)
  })
  @Cron(Expression.EVERY_SECOND)
  doSomething() {}
}

Retry

🔦 @Cron.Retry(options)

If your job throw an error, you can retry it with a specify number of attempts, and specify backoff behavior.

  • attempts: Number of retry attempts.
  • delay: Delay between retry attemps in milliseconds.
  • delayFactor: Increases each time the previous delay by a multiplicative factor.
  • delayMax: Caps the maximum delay in milliseconds.
  • condition: Filter function with the error as parameter (so you can retry on specific errors only).
import { Cron, Expression } from '@reflet/cron'

class Jobs {
  @Cron.Retry({ attempts: 3, delay: 100, delayFactor: 2, delayMax: 1000 })
  @Cron(Expression.EVERY_HOUR)
  doSomething() {}
}

If all the attempts failed, then the error is logged to stderr (or handled by Cron.Catch).

Overlaps

🔦 @Cron.PreventOverlap

Prevents the job from firing if the previous occurence is not finished. Useful for potentially long jobs firing every second.
Can be used with or without invokation.

import { Cron, Expression } from '@reflet/cron'

class Jobs {
  @Cron.PreventOverlap
  @Cron(Expression.EVERY_SECOND)
  doSomething() {}
}

Distributed overlaps

🔦 @Cron.PreventOverlap.RedisLock

If your application hosting your cron jobs is running amongst multiple instances, you want to make sure a single job is firing once.
With the help of Redis and the node-redlock package, you can achieve cron locks easily.

You obviously need to install a Redis server, a nodejs Redis client, and node-redlock.

import * as redis from 'redis'
import * as Redlock from 'redlock'
import { Cron, Expression } from '@reflet/cron'

const redisClient = redis.createClient()

@Cron.PreventOverlap.RedisLock((job) => {
  const redlock = new Redlock([redisClient], { retryCount: 0 })
  return redlock.lock(`lock:${job.name}`, 1000)
  // use each job's name as a unique resource ("Jobs.doSomething" in this case).
})
class Jobs {
  @Cron(Expression.EVERY_MINUTE)
  doSomething() {}
}

Reflet will handle the unlocking, once the job is over.
If @Cron.Retry is also applied, the lock will be reacquired on retries with the original ttl and the eventual retry delay.

🗣️ By default, redlock retryCount is set to 10, the documentation recommends to set retryCount to 0 for most tasks (especially for high frequency jobs).

In that regard, if you want to have a look at failed locks, you can simply .catch your lock promise:

return redlock.lock(`lock:${job.name}`, 1000).catch(console.warn)

Go to node-redlock documentation for more details.

Options flexibility

Single decorator

🔦 @Cron.Options(allOptions)

If you prefer, you can use @Cron.Options to group all your options in a single decorator:

import { Cron } from '@reflet/cron'

class Jobs {
  @Cron.Options({
    start: true,
    runOnInit: true,
    retry: { attempts: 3, delay: 200 },
    catchError: async (err) => {
      await db.insert(err)
    }
  })
  @Cron('* * * * *')
  doSomething() {}
}

Share and override

Attach any option decorator to the class to share it with all jobs. You can override it by reattaching it to the method.

Boolean option decorators (@Cron.Start, @Cron.RunOnInit, @Cron.UnrefTimeout, @Cron.PreventOverlap) can be turned to false with their special sub decorator: @Cron.XXX.Dont (can be used with or without invokation)

@Cron.Start
@Cron.PreventOverlap
@Cron.Retry({ maxRetries: 3, delay: 200 })
class Jobs {
  @Cron(Expression.EVERY_SECOND)
  doSomething() {}

  @Cron.Start.Dont
  @Cron(Expression.EVERY_SECOND)
  doSomethingElse() {}
}

Current job access

🔦 @CurrentJob

You might need to access the current job instance from its own onTick function, for example to simply stop it.
Can be used with or without invokation.

import { Cron, Expression, CurrentJob } from '@reflet/cron'

class Jobs {
  @Cron(Expression.EVERY_SECOND)
  doSomething(@CurrentJob job: Job) {
    job.stop()
  }
}

Dynamic jobs

You can add dynamic cron jobs like so:

import { Cron, Expression, initCronJobs } from '@reflet/cron'

@Cron.Start
class Jobs {
  @Cron(Expression.EVERY_SECOND)
  doSomething() {}
}

const jobs = initCronJobs(Jobs)

jobs.set('doSomethingElse', {
  cronTime: Expression.EVERY_HOUR,
  onTick() {
    console.log("I'm a dynamic job")
  },
  runOnInit: true,
  preventOverlap: true
})

Dynamic jobs inherit shared class options.

Pure dependency injection

If you want to go full OOP and your job classes has constructor dependencies, Reflet will enforce passing them as instances (along with their dependencies) instead of classes.

import { Cron, Expression, initCronJobs } from '@reflet/cron'

class Service {
  user = 'Jeremy'
}

class Jobs {
  static init(...deps: ConstructorParameters<typeof Jobs>) {
    return initCronJobs(new Jobs(...deps))
  }

  constructor(private service: Service) {}

  @Cron(Expression.EVERY_10_MINUTES)
  doSomething() {
    console.log(this.service.user)
  }
}

const jobs = Jobs.init(new Service())

Dependencies (0)

    Dev Dependencies (6)

    Package Sidebar

    Install

    npm i @reflet/cron

    Weekly Downloads

    562

    Version

    1.3.1

    License

    MIT

    Unpacked Size

    79.5 kB

    Total Files

    12

    Last publish

    Collaborators

    • jeben