├── .gitignore ├── LICENSE ├── README.md ├── example └── index.js ├── giphy.gif ├── index.js ├── lib ├── cron.js ├── errors.js └── plugin.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Xpepermint (Kristijan Sedlak) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | Thanks to http://giphy.com/ for the logo image :). 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [mongoose](http://mongoosejs.com)-cron 2 | 3 | > MongoDB collection as crontab 4 | 5 | MongooseCron is build on top of [MongoDB](https://www.mongodb.org) and [Mongoose](http://mongoosejs.com). It offers a simple API for scheduling tasks and running recurring jobs on one or multiple database collections, supporting models and discriminators. It's fast, minimizes processing overhead and it uses atomic commands to ensure safe job executions in cluster environments. 6 | 7 | 8 | 9 | ## Related 10 | 11 | * [mongodb-cron](https://github.com/xpepermint/mongodb-cron): MongoDB collection as crontab, using the officially supported [Node.js driver for MongoDB](https://docs.mongodb.com/ecosystem/drivers/node-js/). 12 | 13 | ## Setup 14 | 15 | ``` 16 | $ npm install --save mongoose-cron 17 | ``` 18 | 19 | ## Quick Start 20 | 21 | Let's say we have a simple application like the one below. 22 | 23 | ```js 24 | import mongoose from 'mongoose'; 25 | 26 | let db = mongoose.connect('mongodb://localhost:27017/testdb'); 27 | let schema = new mongoose.Schema({name: String}); 28 | let Task = db.model('Task', schema); 29 | ``` 30 | 31 | To convert the Task model into a crontab collection, attach the plugin, create a cron worker instance, then call the `start` method on it to start processing. 32 | 33 | ```js 34 | import {cronPlugin} from 'mongoose-cron'; 35 | 36 | let schema = new mongoose.Schema({name: String}); 37 | schema.plugin(cronPlugin, { 38 | handler: doc => console.log('processing', doc) // function or promise 39 | }); 40 | 41 | let Task = db.model('Task', schema); 42 | let cron = Task.createCron().start(); // call `cron.stop()` to stop processing 43 | ``` 44 | 45 | We can now create our first job. 46 | 47 | ```js 48 | Task.create({ 49 | cron: { 50 | enabled: true, 51 | startAt: new Date('2015-12-12'), 52 | stopAt: new Date('2016-12-12'), 53 | interval: '* * * * * *' // run every second 54 | } 55 | }); 56 | ``` 57 | 58 | **IMPORTANT:** Any document in the `tasks` collection above can become a cron job. We just have to set at least the `cron.enabled` field to `true`. 59 | 60 | ## Configuration & Details 61 | 62 | The package includes several useful methods and configuration options. We can configure cron functionality by passing the additional options to the plugin or by passing them directly to the `Task.createCron` method. 63 | 64 | ```js 65 | schema.plugin(cronPlugin, { 66 | ... 67 | // When there are no jobs to process, wait 30s before 68 | // checking for processable jobs again (default: 0). 69 | idleDelay: 30000, 70 | // Wait 60s before processing the same job again in case 71 | // the job is a recurring job (default: 0). 72 | nextDelay: 60000, 73 | // Object or Array of Objects to add to the find query. 74 | // The value is concatinated with the $and operator. 75 | // (default: []) 76 | addToQuery: { version: { $lte: 1 } } 77 | }); 78 | ``` 79 | 80 | We can create **recurring** or **one-time** jobs. Every time the job processing starts the `cron.startedAt` field is replaced with the current date and the `cron.locked` field is set to `true`. When the processing ends the `cron.processedAt` field is updated to the current date and the `cron.locked` field is removed. 81 | 82 | We can create a one-time job which will start processing immediately just by setting the `cron.enabled` field to `true`. 83 | 84 | ```js 85 | model.create({ 86 | cron: { 87 | enabled: true 88 | } 89 | }); 90 | ``` 91 | 92 | Job execution can be delayed by setting the `cron.startAt` field. 93 | 94 | ```js 95 | model.create({ 96 | cron: { 97 | ... 98 | startAt: new Date('2016-01-01') 99 | } 100 | }); 101 | ``` 102 | 103 | By setting the `cron.interval` field we define a recurring job. 104 | 105 | ```js 106 | model.create({ 107 | cron: { 108 | ... 109 | interval: '* * * * * *' // every second 110 | } 111 | }); 112 | ``` 113 | 114 | The interval above consists of 6 values. 115 | 116 | ``` 117 | * * * * * * 118 | ┬ ┬ ┬ ┬ ┬ ┬ 119 | │ │ │ │ │ | 120 | │ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun) 121 | │ │ │ │ └───── month (1 - 12) 122 | │ │ │ └────────── day of month (1 - 31) 123 | │ │ └─────────────── hour (0 - 23) 124 | │ └──────────────────── minute (0 - 59) 125 | └───────────────────────── second (0 - 59) 126 | ``` 127 | 128 | A recurring job will repeat endlessly unless we limit that by setting the `cron.stopAt` field. When a job expires it stops repeating. If we also set `cron.removeExpired` field to `true`, a job is automatically deleted. 129 | 130 | ```js 131 | model.create({ 132 | cron: { 133 | enabled: true, 134 | startAt: new Date('2016-01-01'), 135 | interval: '* * * * * *', 136 | stopAt: new Date('2020-01-01'), 137 | removeExpired: true 138 | } 139 | }); 140 | ``` 141 | 142 | ## Example 143 | 144 | You can run the attached example with the `npm run example` command. 145 | 146 | ## Alternatives 147 | 148 | There is a very similar package called [mongodb-cron](https://github.com/xpepermint/mongodb-cron), which uses the [officially supported Node.js driver](https://docs.mongodb.com/ecosystem/drivers/node-js/) for MongoDB. 149 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* initializing mongodb */ 4 | 5 | const mongoose = require('mongoose'); 6 | const dbhost = process.env.DB_HOST || 'localhost:27017'; 7 | const dbname = process.env.DB_NAME || 'testdb'; 8 | const db = mongoose.connect(`mongodb://${dbhost}/${dbname}`); 9 | 10 | /* defining polymorphic model with support for cron */ 11 | 12 | const {cronPlugin} = require('..'); 13 | 14 | let noteSchema = new mongoose.Schema({ 15 | name: {type: String} 16 | }); 17 | let checklistSchema = new mongoose.Schema({ 18 | description: {type: String} 19 | }); 20 | let reminderSchema = new mongoose.Schema({ 21 | description: {type: String} 22 | }); 23 | 24 | noteSchema.plugin(cronPlugin, { 25 | handler: doc => console.log('processing', doc.id) 26 | }); 27 | 28 | let Note = db.model('Note', noteSchema); 29 | let Checklist = Note.discriminator('Checklist', checklistSchema); 30 | let Reminder = Note.discriminator('Reminder', reminderSchema); 31 | 32 | /* creating cron worker and starting the heartbit */ 33 | 34 | let cron = Note.createCron().start(); 35 | 36 | /* sedding */ 37 | 38 | Checklist.findOneAndUpdate({_id: '565781bba17d0e685f8e2086'}, { 39 | name: 'Job 1', 40 | description: 'ignored by the cron heartbit' 41 | }, {upsert: true, setDefaultsOnInsert: true, new: true}).then(res => {}).catch(console.log); 42 | 43 | Reminder.findOneAndUpdate({_id: '565781bba17d0e685f8e2087'}, { 44 | name: 'Job 2', 45 | description: 'remined me every 1s', 46 | cron: { 47 | enabled: true, 48 | startAt: new Date(), 49 | interval: '* * * * * *' 50 | } 51 | }, {upsert: true, setDefaultsOnInsert: true, new: true}).then(res => {}).catch(console.log); 52 | -------------------------------------------------------------------------------- /giphy.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xpepermint/mongoose-cron/28ccb481b693bdb9f9e8679b467d4d175151bdcd/giphy.gif -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports.cronPlugin = require('./lib/plugin'); 4 | -------------------------------------------------------------------------------- /lib/cron.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const moment = require('moment'); 4 | const later = require('later'); 5 | const events = require('events'); 6 | const errors = require('./errors'); 7 | 8 | module.exports = class Cron extends events.EventEmitter { 9 | constructor(model, config) { 10 | super(); 11 | 12 | this._model = model; 13 | this._handler = config.handler; 14 | this._running = false; 15 | this._heartbeat = null; 16 | this._idleDelay = config.idleDelay || 0; // when there are no jobs for processing, wait 0 sek before continue 17 | this._nextDelay = config.nextDelay || 0; // wait 0 min before processing the same job again 18 | this._addToQuery = config.addToQuery || []; 19 | } 20 | 21 | /* 22 | * Returns true if the cron is running. 23 | */ 24 | 25 | isRunning() { 26 | return this._running; 27 | } 28 | 29 | /* 30 | * Starts the heartbit. 31 | */ 32 | 33 | start() { 34 | if (this._running) return this; 35 | 36 | this._running = true; 37 | this._nextTick(); 38 | 39 | return this; 40 | } 41 | 42 | /* 43 | * Stops the heartbit of the schedule. 44 | */ 45 | 46 | stop() { 47 | clearTimeout(this._heartbeat); 48 | this._running = false; 49 | 50 | return this; 51 | } 52 | 53 | /* 54 | * Returns the next date when the job should be processed or `null` if the job 55 | * is expired or not recurring. 56 | */ 57 | 58 | getNextStart(doc) { 59 | if (!doc.cron.interval) { // not recurring job 60 | return null; 61 | } 62 | 63 | let future = moment().add(this._nextDelay, 'millisecond'); // date when the next start is possible 64 | let start = moment(doc.cron.startAt); 65 | if (start >= future) { // already in future 66 | return doc.cron.startAt; 67 | } 68 | 69 | try { // new date 70 | let schedule = later.parse.cron(doc.cron.interval, true); 71 | let dates = later.schedule(schedule).next(2, future.toDate(), doc.cron.stopAt); 72 | return dates[1]; 73 | } catch (err) { 74 | return null; 75 | } 76 | } 77 | 78 | /* 79 | * Private method which is called on every heartbit. 80 | */ 81 | 82 | _tick() { 83 | if (!this._running) return; 84 | 85 | let tickDate = new Date(); 86 | let doc = null; 87 | return this._model.findOneAndUpdate( 88 | {$and: [ 89 | {'cron.enabled': true, 'cron.locked': {$exists: false}}, 90 | {$or: [{'cron.startAt': {$lte: tickDate}}, {'cron.startAt': {$exists: false}}]}, 91 | {$or: [{'cron.stopAt': {$gte: tickDate}}, {'cron.stopAt': {$exists: false}}]} 92 | ].concat(this._addToQuery)}, 93 | {'cron.locked': true, 94 | 'cron.startedAt': tickDate 95 | }, 96 | {sort: {'cron.startAt': 1}} 97 | ).then(res => { 98 | doc = res; 99 | return this._handleDocument(doc); 100 | }).then(res => { 101 | return this._rescheduleDocument(doc); 102 | }).then(res => { 103 | return this._nextTick(); 104 | }).catch(err => { 105 | return this._handleError(err, doc); 106 | }); 107 | } 108 | 109 | /* 110 | * Private method which starts the next tick. 111 | */ 112 | 113 | _nextTick(delay) { 114 | if (!delay) { 115 | return this._tick(); 116 | } else { 117 | clearTimeout(this._heartbeat); 118 | this._heartbeat = setTimeout(this._tick.bind(this), delay); 119 | } 120 | } 121 | 122 | /* 123 | * Private method which processes a document of a tick. 124 | */ 125 | 126 | _handleDocument(doc) { 127 | if (!doc) { 128 | throw new errors.CronNoDocumentError(); 129 | } else { 130 | return Promise.resolve().then(res => this._handler(doc)); 131 | } 132 | } 133 | 134 | /* 135 | * Private method which tries to reschedule a document, marks it as expired or 136 | * deletes a job if `removeExpired` is set to `true`. 137 | */ 138 | 139 | _rescheduleDocument(doc) { 140 | let nextStart = this.getNextStart(doc); 141 | if (!nextStart) { 142 | if (doc.cron.removeExpired === true) { 143 | return doc.remove(); // delete 144 | } else { 145 | return doc.update({$unset: {'cron.enabled': 1, 'cron.locked': 1, 'cron.lastError': 1}, 'cron.processedAt': new Date()}); // mark as expired 146 | } 147 | } else { 148 | return doc.update({$unset: {'cron.locked': 1, 'cron.lastError': 1}, 'cron.processedAt': new Date(), 'cron.startAt': nextStart}); // continue 149 | } 150 | } 151 | 152 | /* 153 | * Private method for handling errors. 154 | */ 155 | 156 | _handleError(err, doc) { 157 | let delay = 0; 158 | let promise = Promise.resolve(); 159 | 160 | switch(err.name) { 161 | case 'CronNoDocumentError': 162 | delay = this._idleDelay; 163 | break; 164 | default: 165 | promise = promise.then(res => { 166 | return doc.update({$unset: {'cron.enabled': 1, 'cron.locked': 1}, 'cron.lastError': err.message}); 167 | }); 168 | } 169 | return promise.then(res => this._nextTick(delay)); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class CronNoDocumentError extends Error { 4 | constructor(message) { 5 | super(message); 6 | this.name = 'CronNoDocumentError'; 7 | this.message = message || 'No document for processing.'; 8 | } 9 | } 10 | 11 | module.exports = {CronNoDocumentError}; 12 | -------------------------------------------------------------------------------- /lib/plugin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const mongoose = require('mongoose'); 4 | const Cron = require('./cron'); 5 | 6 | module.exports = function(schema, options) { 7 | if (!options) options = {}; 8 | 9 | schema.add({ 10 | cron: new mongoose.Schema({ 11 | enabled: { // on/off switch 12 | type: Boolean 13 | }, 14 | startAt: { // first possible start date 15 | type: Date 16 | }, 17 | stopAt: { // last possible start date (`null` if not recurring) 18 | type: Date 19 | }, 20 | interval: { // cron string interval (e.g. `* * * * * *`) 21 | type: String 22 | }, 23 | removeExpired: { // set to `true` for the expired jobs to be automatically deleted 24 | type: Boolean 25 | }, 26 | startedAt: { // (automatic) set every time a job processing starts 27 | type: Date 28 | }, 29 | processedAt: { // (automatic) set every time a job processing ends 30 | type: Date 31 | }, 32 | locked: { // (automatic) `true` when job is processing 33 | type: Boolean 34 | }, 35 | lastError: { // (automatic) last error message 36 | type: String 37 | } 38 | }, {_id: false}) 39 | }); 40 | 41 | schema.index( 42 | {'cron.enabled': 1, 'cron.locked': 1, 'cron.startAt': 1, 'cron.stopAt': 1} 43 | ); 44 | 45 | schema.statics.createCron = function(config) { 46 | return new Cron(this, Object.assign({}, options, config)); 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mongoose-cron", 3 | "version": "0.5.7", 4 | "description": "MongoDB collection as crontab", 5 | "main": "index.js", 6 | "scripts": { 7 | "example": "node --harmony --harmony-destructuring example/index.js", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/xpepermint/mongoose-cron.git" 13 | }, 14 | "keywords": [ 15 | "mongoose", 16 | "mongodb", 17 | "cron", 18 | "crontab", 19 | "schedule", 20 | "scheduler", 21 | "job", 22 | "jobs", 23 | "task", 24 | "tasks", 25 | "recurring", 26 | "repeated", 27 | "collection" 28 | ], 29 | "author": "Xpepermint", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/xpepermint/mongoose-cron/issues" 33 | }, 34 | "homepage": "https://github.com/xpepermint/mongoose-cron#readme", 35 | "devDependencies": {}, 36 | "dependencies": { 37 | "later": "1.2.0", 38 | "moment": "2.15.1", 39 | "mongoose": "4.6.1" 40 | } 41 | } 42 | --------------------------------------------------------------------------------