├── .gitignore ├── .npm └── package │ ├── .gitignore │ ├── README │ └── npm-shrinkwrap.json ├── .versions ├── LICENSE ├── README.md ├── example ├── .meteor │ ├── .finished-upgraders │ ├── .gitignore │ ├── .id │ ├── packages │ ├── platforms │ ├── release │ └── versions ├── example.css ├── example.html └── example.js ├── package.js ├── synced-cron-server.js ├── synced-cron-tests.js └── versions.json /.gitignore: -------------------------------------------------------------------------------- 1 | .build* 2 | 3 | .idea/ 4 | -------------------------------------------------------------------------------- /.npm/package/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.npm/package/README: -------------------------------------------------------------------------------- 1 | This directory and the files immediately inside it are automatically generated 2 | when you change this package's NPM dependencies. Commit the files in this 3 | directory (npm-shrinkwrap.json, .gitignore, and this README) to source control 4 | so that others run the same versions of sub-dependencies. 5 | 6 | You should NOT check in the node_modules directory that Meteor automatically 7 | creates; if you are using git, the .gitignore file tells git to ignore it. 8 | -------------------------------------------------------------------------------- /.npm/package/npm-shrinkwrap.json: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "dependencies": { 4 | "later": { 5 | "version": "1.1.6", 6 | "resolved": "https://registry.npmjs.org/later/-/later-1.1.6.tgz", 7 | "integrity": "sha1-Wvg61IJjk8VvEO4u4ekQlkRb5Os=" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.versions: -------------------------------------------------------------------------------- 1 | allow-deny@1.1.1 2 | babel-compiler@7.10.4 3 | babel-runtime@1.5.1 4 | base64@1.0.12 5 | binary-heap@1.0.11 6 | boilerplate-generator@1.7.1 7 | callback-hook@1.5.1 8 | check@1.3.2 9 | ddp@1.4.1 10 | ddp-client@2.6.1 11 | ddp-common@1.4.0 12 | ddp-server@2.6.2 13 | diff-sequence@1.1.2 14 | dynamic-import@0.7.3 15 | ecmascript@0.16.7 16 | ecmascript-runtime@0.8.1 17 | ecmascript-runtime-client@0.12.1 18 | ecmascript-runtime-server@0.11.0 19 | ejson@1.1.3 20 | fetch@0.1.3 21 | geojson-utils@1.0.11 22 | id-map@1.1.1 23 | inter-process-messaging@0.1.1 24 | local-test:percolate:synced-cron@1.5.2 25 | logging@1.3.2 26 | meteor@1.11.3 27 | minimongo@1.9.3 28 | modern-browsers@0.1.9 29 | modules@0.19.0 30 | modules-runtime@0.13.1 31 | mongo@1.16.7 32 | mongo-decimal@0.1.3 33 | mongo-dev-server@1.1.0 34 | mongo-id@1.0.8 35 | npm-mongo@4.16.0 36 | ordered-dict@1.1.0 37 | percolate:synced-cron@1.5.2 38 | promise@0.12.2 39 | random@1.2.1 40 | react-fast-refresh@0.2.7 41 | reload@1.3.1 42 | retry@1.1.0 43 | routepolicy@1.1.1 44 | socket-stream-client@0.5.1 45 | tinytest@1.2.2 46 | tracker@1.3.2 47 | underscore@1.0.13 48 | webapp@1.13.5 49 | webapp-hashing@1.1.1 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Percolate Studio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # percolate:synced-cron 2 | 3 | A simple cron system for [Meteor](http://meteor.com). It supports syncronizing jobs between multiple processes. In other words, if you add a job that runs every hour and your deployment consists of multiple app servers, only one of the app servers will execute the job each time (whichever tries first). 4 | 5 | ## Migrated from percolate:synced-cron littledata:synced-cron 6 | 7 | Since the original creator of the project could no longer maintain it, we had to migrate the package to another organisation to allow further maintenance and updates. 8 | 9 | To migrate you can simply run 10 | 11 | ``` sh 12 | $ meteor remove percolate:synced-cron && meteor add littledata:synced-cron 13 | ``` 14 | 15 | ## Installation 16 | 17 | ``` sh 18 | $ meteor add littledata:synced-cron 19 | ``` 20 | 21 | ## API 22 | 23 | ### Basics 24 | 25 | To write a cron job, give it a unique name, a schedule and a function to run like below. SyncedCron uses the fantastic [later.js](http://bunkat.github.io/later/) library behind the scenes. A Later.js `parse` object is passed into the schedule call that gives you a huge amount of flexibility for scheduling your jobs, see the [documentation](http://bunkat.github.io/later/parsers.html#overview). 26 | 27 | ``` js 28 | SyncedCron.add({ 29 | name: 'Crunch some important numbers for the marketing department', 30 | schedule: function(parser) { 31 | // parser is a later.parse object 32 | return parser.text('every 2 hours'); 33 | }, 34 | job: function() { 35 | var numbersCrunched = CrushSomeNumbers(); 36 | return numbersCrunched; 37 | } 38 | }); 39 | ``` 40 | 41 | To start processing your jobs, somewhere in your project add: 42 | 43 | ``` js 44 | SyncedCron.start(); 45 | ``` 46 | 47 | ### Advanced 48 | 49 | SyncedCron uses a collection called `cronHistory` to syncronize between processes. This also serves as a useful log of when jobs ran along with their output or error. A sample item looks like: 50 | 51 | ``` js 52 | { _id: 'wdYLPBZp5zzbwdfYj', 53 | intendedAt: Sun Apr 13 2014 17:34:00 GMT-0700 (MST), 54 | finishedAt: Sun Apr 13 2014 17:34:01 GMT-0700 (MST), 55 | name: 'Crunch some important numbers for the marketing department', 56 | startedAt: Sun Apr 13 2014 17:34:00 GMT-0700 (MST), 57 | result: '1982 numbers crunched' 58 | } 59 | ``` 60 | 61 | Call `SyncedCron.nextScheduledAtDate(jobName)` to find the date that the job 62 | referenced by `jobName` will run next. 63 | 64 | Call `SyncedCron.remove(jobName)` to remove and stop running the job referenced by jobName. 65 | 66 | Call `SyncedCron.stop()` to remove and stop all jobs. 67 | 68 | Call `SyncedCron.pause()` to stop all jobs without removing them. The existing jobs can be rescheduled (i.e. restarted) with `SyncedCron.start()`. 69 | 70 | To schedule a once off (i.e not recurring) event, create a job with a schedule like this `parser.recur().on(date).fullDate();` 71 | 72 | ### Configuration 73 | 74 | You can configure SyncedCron with the `config` method. Defaults are: 75 | 76 | ``` js 77 | SyncedCron.config({ 78 | // Log job run details to console 79 | log: true, 80 | 81 | // Use a custom logger function (defaults to Meteor's logging package) 82 | logger: null, 83 | 84 | // Name of collection to use for synchronisation and logging 85 | collectionName: 'cronHistory', 86 | 87 | // Default to using localTime 88 | utc: false, 89 | 90 | /* 91 | TTL in seconds for history records in collection to expire 92 | NOTE: Unset to remove expiry but ensure you remove the index from 93 | mongo by hand 94 | 95 | ALSO: SyncedCron can't use the `_ensureIndex` command to modify 96 | the TTL index. The best way to modify the default value of 97 | `collectionTTL` is to remove the index by hand (in the mongo shell 98 | run `db.cronHistory.dropIndex({startedAt: 1})`) and re-run your 99 | project. SyncedCron will recreate the index with the updated TTL. 100 | */ 101 | collectionTTL: 172800 102 | }); 103 | ``` 104 | 105 | ### Logging 106 | 107 | SyncedCron uses Meteor's `logging` package by default. If you want to use your own logger (for sending to other consumers or similar) you can do so by configuring the `logger` option. 108 | 109 | SyncedCron expects a function as `logger`, and will pass arguments to it for you to take action on. 110 | 111 | ```js 112 | var MyLogger = function(opts) { 113 | console.log('Level', opts.level); 114 | console.log('Message', opts.message); 115 | console.log('Tag', opts.tag); 116 | } 117 | 118 | SyncedCron.config({ 119 | logger: MyLogger 120 | }); 121 | 122 | SyncedCron.add({ name: 'Test Job', ... }); 123 | SyncedCron.start(); 124 | ``` 125 | 126 | The `opts` object passed to `MyLogger` above includes `level`, `message`, and `tag`. 127 | 128 | - `level` will be one of `info`, `warn`, `error`, `debug`. 129 | - `message` is something like `Scheduled "Test Job" next run @Fri Mar 13 2015 10:15:00 GMT+0100 (CET)`. 130 | - `tag` will always be `"SyncedCron"` (handy for filtering). 131 | 132 | 133 | ## Caveats 134 | 135 | Beware, SyncedCron probably won't work as expected on certain shared hosting providers that shutdown app instances when they aren't receiving requests (like Heroku's free dyno tier or Meteor free galaxy). 136 | 137 | ## Contributing 138 | 139 | Write some code. Write some tests. To run the tests, do: 140 | 141 | ``` sh 142 | $ meteor test-packages ./ 143 | ``` 144 | 145 | ## License 146 | 147 | MIT. (c) Percolate Studio, originally designed and built by Zoltan Olah (@zol), now community maintained. 148 | 149 | Synced Cron was developed as part of the [Verso](http://versoapp.com) project. 150 | -------------------------------------------------------------------------------- /example/.meteor/.finished-upgraders: -------------------------------------------------------------------------------- 1 | # This file contains information which helps Meteor properly upgrade your 2 | # app when you run 'meteor update'. You should check it into version control 3 | # with your project. 4 | 5 | notices-for-0.9.0 6 | notices-for-0.9.1 7 | 0.9.4-platform-file 8 | notices-for-facebook-graph-api-2 9 | -------------------------------------------------------------------------------- /example/.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /example/.meteor/.id: -------------------------------------------------------------------------------- 1 | # This file contains a token that is unique to your project. 2 | # Check it into your repository along with the rest of this directory. 3 | # It can be used for purposes such as: 4 | # - ensuring you don't accidentally deploy one app on top of another 5 | # - providing package authors with aggregated statistics 6 | 7 | 18n88nbddjnt450w00w 8 | -------------------------------------------------------------------------------- /example/.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # 3 | # 'meteor add' and 'meteor remove' will edit this file for you, 4 | # but you can also edit it by hand. 5 | 6 | standard-app-packages 7 | autopublish 8 | insecure 9 | percolate:synced-cron 10 | -------------------------------------------------------------------------------- /example/.meteor/platforms: -------------------------------------------------------------------------------- 1 | browser 2 | server 3 | -------------------------------------------------------------------------------- /example/.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@1.3 2 | -------------------------------------------------------------------------------- /example/.meteor/versions: -------------------------------------------------------------------------------- 1 | autopublish@1.0.3 2 | autoupdate@1.2.1 3 | base64@1.0.3 4 | binary-heap@1.0.3 5 | blaze@2.1.2 6 | blaze-tools@1.0.3 7 | boilerplate-generator@1.0.3 8 | callback-hook@1.0.3 9 | check@1.0.5 10 | ddp@1.1.0 11 | deps@1.0.7 12 | ejson@1.0.6 13 | fastclick@1.0.3 14 | geojson-utils@1.0.3 15 | html-tools@1.0.4 16 | htmljs@1.0.4 17 | http@1.1.0 18 | id-map@1.0.3 19 | insecure@1.0.3 20 | jquery@1.11.3_2 21 | json@1.0.3 22 | launch-screen@1.0.2 23 | livedata@1.0.13 24 | logging@1.0.7 25 | meteor@1.1.6 26 | meteor-platform@1.2.2 27 | minifiers@1.1.5 28 | minimongo@1.0.8 29 | mobile-status-bar@1.0.3 30 | mongo@1.1.0 31 | observe-sequence@1.0.6 32 | ordered-dict@1.0.3 33 | percolate:synced-cron@1.2.2 34 | random@1.0.3 35 | reactive-dict@1.1.0 36 | reactive-var@1.0.5 37 | reload@1.1.3 38 | retry@1.0.3 39 | routepolicy@1.0.5 40 | session@1.1.0 41 | spacebars@1.0.6 42 | spacebars-compiler@1.0.6 43 | standard-app-packages@1.0.5 44 | templating@1.1.1 45 | tracker@1.0.7 46 | ui@1.0.6 47 | underscore@1.0.3 48 | url@1.0.4 49 | webapp@1.2.0 50 | webapp-hashing@1.0.3 51 | -------------------------------------------------------------------------------- /example/example.css: -------------------------------------------------------------------------------- 1 | /* CSS declarations go here */ 2 | -------------------------------------------------------------------------------- /example/example.html: -------------------------------------------------------------------------------- 1 | 2 | example 3 | 4 | 5 | 6 | {{> hello}} 7 | 8 | 9 | 14 | -------------------------------------------------------------------------------- /example/example.js: -------------------------------------------------------------------------------- 1 | if (Meteor.isClient) { 2 | Template.hello.greeting = function () { 3 | return "Welcome to example."; 4 | }; 5 | 6 | Template.hello.events({ 7 | 'click input': function () { 8 | // template data, if any, is available in 'this' 9 | if (typeof console !== 'undefined') 10 | console.log("You pressed the button"); 11 | } 12 | }); 13 | } 14 | 15 | if (Meteor.isServer) { 16 | // optionally set the collection's name that synced cron will use 17 | SyncedCron.config({ 18 | collectionName: 'somethingDifferent' 19 | }); 20 | 21 | SyncedCron.add({ 22 | name: 'Crunch some important numbers for the marketing department', 23 | schedule: function(parser) { 24 | // parser is a later.parse object 25 | return parser.text('every 5 seconds'); 26 | }, 27 | job: function(intendedAt) { 28 | console.log('crunching numbers'); 29 | console.log('job should be running at:'); 30 | console.log(intendedAt); 31 | } 32 | }); 33 | 34 | Meteor.startup(function () { 35 | // code to run on server at startup 36 | SyncedCron.start(); 37 | 38 | // Stop jobs after 15 seconds 39 | Meteor.setTimeout(function() { SyncedCron.stop(); }, 15 * 1000); 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | summary: "Define and run scheduled jobs across multiple servers.", 3 | version: "1.5.2", 4 | name: "percolate:synced-cron", 5 | git: "https://github.com/percolatestudio/meteor-synced-cron.git" 6 | }); 7 | 8 | Npm.depends({later: "1.1.6"}); 9 | 10 | Package.onUse(function (api) { 11 | api.versionsFrom('METEOR@1.3'); 12 | api.use(['underscore', 'check', 'mongo', 'logging'], 'server'); 13 | api.addFiles(['synced-cron-server.js'], "server"); 14 | api.export('SyncedCron', 'server'); 15 | }); 16 | 17 | Package.onTest(function (api) { 18 | api.use(['check', 'mongo'], 'server'); 19 | api.use(['tinytest', 'underscore', 'logging']); 20 | api.addFiles(['synced-cron-server.js', 'synced-cron-tests.js'], ['server']); 21 | }); 22 | -------------------------------------------------------------------------------- /synced-cron-server.js: -------------------------------------------------------------------------------- 1 | // A package for running jobs synchronized across multiple processes 2 | SyncedCron = { 3 | _entries: {}, 4 | running: false, 5 | options: { 6 | //Log job run details to console 7 | log: true, 8 | 9 | logger: null, 10 | 11 | //Name of collection to use for synchronisation and logging 12 | collectionName: 'cronHistory', 13 | 14 | //Default to using localTime 15 | utc: false, 16 | 17 | //TTL in seconds for history records in collection to expire 18 | //NOTE: Unset to remove expiry but ensure you remove the index from 19 | //mongo by hand 20 | collectionTTL: 172800 21 | }, 22 | config: function(opts) { 23 | this.options = _.extend({}, this.options, opts); 24 | } 25 | } 26 | 27 | Later = Npm.require('later'); 28 | 29 | /* 30 | Logger factory function. Takes a prefix string and options object 31 | and uses an injected `logger` if provided, else falls back to 32 | Meteor's `Log` package. 33 | 34 | Will send a log object to the injected logger, on the following form: 35 | 36 | message: String 37 | level: String (info, warn, error, debug) 38 | tag: 'SyncedCron' 39 | */ 40 | function createLogger(prefix) { 41 | check(prefix, String); 42 | 43 | // Return noop if logging is disabled. 44 | if(SyncedCron.options.log === false) { 45 | return function() {}; 46 | } 47 | 48 | return function(level, message) { 49 | check(level, Match.OneOf('info', 'error', 'warn', 'debug')); 50 | check(message, String); 51 | 52 | var logger = SyncedCron.options && SyncedCron.options.logger; 53 | 54 | if(logger && _.isFunction(logger)) { 55 | 56 | logger({ 57 | level: level, 58 | message: message, 59 | tag: prefix 60 | }); 61 | 62 | } else { 63 | Log[level]({ message: prefix + ': ' + message }); 64 | } 65 | } 66 | } 67 | 68 | var log; 69 | 70 | Meteor.startup(function() { 71 | var options = SyncedCron.options; 72 | 73 | log = createLogger('SyncedCron'); 74 | 75 | ['info', 'warn', 'error', 'debug'].forEach(function(level) { 76 | log[level] = _.partial(log, level); 77 | }); 78 | 79 | // Don't allow TTL less than 5 minutes so we don't break synchronization 80 | var minTTL = 300; 81 | 82 | // Use UTC or localtime for evaluating schedules 83 | if (options.utc) 84 | Later.date.UTC(); 85 | else 86 | Later.date.localTime(); 87 | 88 | // collection holding the job history records 89 | SyncedCron._collection = new Mongo.Collection(options.collectionName); 90 | SyncedCron._collection._ensureIndex({intendedAt: 1, name: 1}, {unique: true}); 91 | 92 | if (options.collectionTTL) { 93 | if (options.collectionTTL > minTTL) 94 | SyncedCron._collection._ensureIndex({startedAt: 1 }, 95 | { expireAfterSeconds: options.collectionTTL } ); 96 | else 97 | log.warn('Not going to use a TTL that is shorter than:' + minTTL); 98 | } 99 | }); 100 | 101 | var scheduleEntry = function(entry) { 102 | var schedule = entry.schedule(Later.parse); 103 | entry._timer = 104 | SyncedCron._laterSetInterval(SyncedCron._entryWrapper(entry), schedule); 105 | 106 | log.info('Scheduled "' + entry.name + '" next run @' 107 | + Later.schedule(schedule).next(1)); 108 | } 109 | 110 | // add a scheduled job 111 | // SyncedCron.add({ 112 | // name: String, //*required* unique name of the job 113 | // schedule: function(laterParser) {},//*required* when to run the job 114 | // job: function() {}, //*required* the code to run 115 | // }); 116 | SyncedCron.add = function(entry) { 117 | check(entry.name, String); 118 | check(entry.schedule, Function); 119 | check(entry.job, Function); 120 | check(entry.persist, Match.Optional(Boolean)); 121 | 122 | if (entry.persist === undefined) { 123 | entry.persist = true; 124 | } 125 | 126 | // check 127 | if (!this._entries[entry.name]) { 128 | this._entries[entry.name] = entry; 129 | 130 | // If cron is already running, start directly. 131 | if (this.running) { 132 | scheduleEntry(entry); 133 | } 134 | } 135 | } 136 | 137 | // Start processing added jobs 138 | SyncedCron.start = function() { 139 | var self = this; 140 | 141 | Meteor.startup(function() { 142 | // Schedule each job with later.js 143 | _.each(self._entries, function(entry) { 144 | scheduleEntry(entry); 145 | }); 146 | self.running = true; 147 | }); 148 | } 149 | 150 | // Return the next scheduled date of the first matching entry or undefined 151 | SyncedCron.nextScheduledAtDate = function(jobName) { 152 | var entry = this._entries[jobName]; 153 | 154 | if (entry) 155 | return Later.schedule(entry.schedule(Later.parse)).next(1); 156 | } 157 | 158 | // Remove and stop the entry referenced by jobName 159 | SyncedCron.remove = function(jobName) { 160 | var entry = this._entries[jobName]; 161 | 162 | if (entry) { 163 | if (entry._timer) 164 | entry._timer.clear(); 165 | 166 | delete this._entries[jobName]; 167 | log.info('Removed "' + entry.name + '"'); 168 | } 169 | } 170 | 171 | // Pause processing, but do not remove jobs so that the start method will 172 | // restart existing jobs 173 | SyncedCron.pause = function() { 174 | if (this.running) { 175 | _.each(this._entries, function(entry) { 176 | entry._timer.clear(); 177 | }); 178 | this.running = false; 179 | } 180 | } 181 | 182 | // Stop processing and remove ALL jobs 183 | SyncedCron.stop = function() { 184 | _.each(this._entries, function(entry, name) { 185 | SyncedCron.remove(name); 186 | }); 187 | this.running = false; 188 | } 189 | 190 | // The meat of our logic. Checks if the specified has already run. If not, 191 | // records that it's running the job, runs it, and records the output 192 | SyncedCron._entryWrapper = function(entry) { 193 | var self = this; 194 | 195 | return function(intendedAt) { 196 | intendedAt = new Date(intendedAt.getTime()); 197 | intendedAt.setMilliseconds(0); 198 | 199 | var jobHistory; 200 | 201 | if (entry.persist) { 202 | jobHistory = { 203 | intendedAt: intendedAt, 204 | name: entry.name, 205 | startedAt: new Date() 206 | }; 207 | 208 | // If we have a dup key error, another instance has already tried to run 209 | // this job. 210 | try { 211 | jobHistory._id = self._collection.insert(jobHistory); 212 | } catch(e) { 213 | // http://www.mongodb.org/about/contributors/error-codes/ 214 | // 11000 == duplicate key error 215 | if (e.code === 11000) { 216 | log.info('Not running "' + entry.name + '" again.'); 217 | return; 218 | } 219 | 220 | throw e; 221 | }; 222 | } 223 | 224 | // run and record the job 225 | try { 226 | log.info('Starting "' + entry.name + '".'); 227 | var output = entry.job(intendedAt,entry.name); // <- Run the actual job 228 | 229 | log.info('Finished "' + entry.name + '".'); 230 | if(entry.persist) { 231 | self._collection.update({_id: jobHistory._id}, { 232 | $set: { 233 | finishedAt: new Date(), 234 | result: output 235 | } 236 | }); 237 | } 238 | } catch(e) { 239 | log.info('Exception "' + entry.name +'" ' + ((e && e.stack) ? e.stack : e)); 240 | if(entry.persist) { 241 | self._collection.update({_id: jobHistory._id}, { 242 | $set: { 243 | finishedAt: new Date(), 244 | error: (e && e.stack) ? e.stack : e 245 | } 246 | }); 247 | } 248 | } 249 | }; 250 | } 251 | 252 | // for tests 253 | SyncedCron._reset = function() { 254 | this._entries = {}; 255 | this._collection.remove({}); 256 | this.running = false; 257 | } 258 | 259 | // --------------------------------------------------------------------------- 260 | // The following two functions are lifted from the later.js package, however 261 | // I've made the following changes: 262 | // - Use Meteor.setTimeout and Meteor.clearTimeout 263 | // - Added an 'intendedAt' parameter to the callback fn that specifies the precise 264 | // time the callback function *should* be run (so we can co-ordinate jobs) 265 | // between multiple, potentially laggy and unsynced machines 266 | 267 | // From: https://github.com/bunkat/later/blob/master/src/core/setinterval.js 268 | SyncedCron._laterSetInterval = function(fn, sched) { 269 | 270 | var t = SyncedCron._laterSetTimeout(scheduleTimeout, sched), 271 | done = false; 272 | 273 | /** 274 | * Executes the specified function and then sets the timeout for the next 275 | * interval. 276 | */ 277 | function scheduleTimeout(intendedAt) { 278 | if(!done) { 279 | try { 280 | fn(intendedAt); 281 | } catch(e) { 282 | log.info('Exception running scheduled job ' + ((e && e.stack) ? e.stack : e)); 283 | } 284 | 285 | t = SyncedCron._laterSetTimeout(scheduleTimeout, sched); 286 | } 287 | } 288 | 289 | return { 290 | 291 | /** 292 | * Clears the timeout. 293 | */ 294 | clear: function() { 295 | done = true; 296 | t.clear(); 297 | } 298 | 299 | }; 300 | 301 | }; 302 | 303 | // From: https://github.com/bunkat/later/blob/master/src/core/settimeout.js 304 | SyncedCron._laterSetTimeout = function(fn, sched) { 305 | 306 | var s = Later.schedule(sched), t; 307 | scheduleTimeout(); 308 | 309 | /** 310 | * Schedules the timeout to occur. If the next occurrence is greater than the 311 | * max supported delay (2147483647 ms) than we delay for that amount before 312 | * attempting to schedule the timeout again. 313 | */ 314 | function scheduleTimeout() { 315 | var now = Date.now(), 316 | next = s.next(2, now); 317 | 318 | // don't schedlue another occurence if no more exist synced-cron#41 319 | if (! next[0]) 320 | return; 321 | 322 | var diff = next[0].getTime() - now, 323 | intendedAt = next[0]; 324 | 325 | // minimum time to fire is one second, use next occurrence instead 326 | if(diff < 1000) { 327 | diff = next[1].getTime() - now; 328 | intendedAt = next[1]; 329 | } 330 | 331 | if(diff < 2147483647) { 332 | t = Meteor.setTimeout(function() { fn(intendedAt); }, diff); 333 | } 334 | else { 335 | t = Meteor.setTimeout(scheduleTimeout, 2147483647); 336 | } 337 | } 338 | 339 | return { 340 | 341 | /** 342 | * Clears the timeout. 343 | */ 344 | clear: function() { 345 | Meteor.clearTimeout(t); 346 | } 347 | 348 | }; 349 | 350 | }; 351 | // --------------------------------------------------------------------------- 352 | -------------------------------------------------------------------------------- /synced-cron-tests.js: -------------------------------------------------------------------------------- 1 | Later = Npm.require('later'); 2 | 3 | Later.date.localTime(); // corresponds to SyncedCron.options.utc: true; 4 | 5 | var TestEntry = { 6 | name: 'Test Job', 7 | schedule: function(parser) { 8 | return parser.cron('15 10 * * ? *'); // not required 9 | }, 10 | job: function() { 11 | return 'ran'; 12 | } 13 | }; 14 | 15 | Tinytest.add('Syncing works', function(test) { 16 | SyncedCron._reset(); 17 | test.equal(SyncedCron._collection.find().count(), 0); 18 | 19 | // added the entry ok 20 | SyncedCron.add(TestEntry); 21 | test.equal(_.keys(SyncedCron._entries).length, 1); 22 | 23 | var entry = SyncedCron._entries[TestEntry.name]; 24 | var intendedAt = new Date(); //whatever 25 | 26 | // first run 27 | SyncedCron._entryWrapper(entry)(intendedAt); 28 | test.equal(SyncedCron._collection.find().count(), 1); 29 | var jobHistory1 = SyncedCron._collection.findOne(); 30 | test.equal(jobHistory1.result, 'ran'); 31 | 32 | // second run 33 | SyncedCron._entryWrapper(entry)(intendedAt); 34 | test.equal(SyncedCron._collection.find().count(), 1); // should still be 1 35 | var jobHistory2 = SyncedCron._collection.findOne(); 36 | test.equal(jobHistory1._id, jobHistory2._id); 37 | }); 38 | 39 | Tinytest.add('Exceptions work', function(test) { 40 | SyncedCron._reset(); 41 | SyncedCron.add(_.extend({}, TestEntry, { 42 | job: function() { 43 | throw new Meteor.Error('Haha, gotcha!'); 44 | } 45 | }) 46 | ); 47 | 48 | var entry = SyncedCron._entries[TestEntry.name]; 49 | var intendedAt = new Date(); //whatever 50 | 51 | // error without result 52 | SyncedCron._entryWrapper(entry)(intendedAt); 53 | test.equal(SyncedCron._collection.find().count(), 1); 54 | var jobHistory1 = SyncedCron._collection.findOne(); 55 | test.equal(jobHistory1.result, undefined); 56 | test.matches(jobHistory1.error, /Haha, gotcha/); 57 | }); 58 | 59 | Tinytest.add('SyncedCron.nextScheduledAtDate works', function(test) { 60 | SyncedCron._reset(); 61 | test.equal(SyncedCron._collection.find().count(), 0); 62 | 63 | // addd 2 entries 64 | SyncedCron.add(TestEntry); 65 | 66 | var entry2 = _.extend({}, TestEntry, { 67 | name: 'Test Job2', 68 | schedule: function(parser) { 69 | return parser.cron('30 11 * * ? *'); 70 | } 71 | }); 72 | SyncedCron.add(entry2); 73 | 74 | test.equal(_.keys(SyncedCron._entries).length, 2); 75 | 76 | SyncedCron.start(); 77 | 78 | var date = SyncedCron.nextScheduledAtDate(entry2.name); 79 | var correctDate = Later.schedule(entry2.schedule(Later.parse)).next(1); 80 | 81 | test.equal(date, correctDate); 82 | }); 83 | 84 | // Tests SyncedCron.remove in the process 85 | Tinytest.add('SyncedCron.stop works', function(test) { 86 | SyncedCron._reset(); 87 | test.equal(SyncedCron._collection.find().count(), 0); 88 | 89 | // addd 2 entries 90 | SyncedCron.add(TestEntry); 91 | 92 | var entry2 = _.extend({}, TestEntry, { 93 | name: 'Test Job2', 94 | schedule: function(parser) { 95 | return parser.cron('30 11 * * ? *'); 96 | } 97 | }); 98 | SyncedCron.add(entry2); 99 | 100 | SyncedCron.start(); 101 | 102 | test.equal(_.keys(SyncedCron._entries).length, 2); 103 | 104 | SyncedCron.stop(); 105 | 106 | test.equal(_.keys(SyncedCron._entries).length, 0); 107 | }); 108 | 109 | Tinytest.add('SyncedCron.pause works', function(test) { 110 | SyncedCron._reset(); 111 | test.equal(SyncedCron._collection.find().count(), 0); 112 | 113 | // addd 2 entries 114 | SyncedCron.add(TestEntry); 115 | 116 | var entry2 = _.extend({}, TestEntry, { 117 | name: 'Test Job2', 118 | schedule: function(parser) { 119 | return parser.cron('30 11 * * ? *'); 120 | } 121 | }); 122 | SyncedCron.add(entry2); 123 | 124 | SyncedCron.start(); 125 | 126 | test.equal(_.keys(SyncedCron._entries).length, 2); 127 | 128 | SyncedCron.pause(); 129 | 130 | test.equal(_.keys(SyncedCron._entries).length, 2); 131 | test.isFalse(SyncedCron.running); 132 | 133 | SyncedCron.start(); 134 | 135 | test.equal(_.keys(SyncedCron._entries).length, 2); 136 | test.isTrue(SyncedCron.running); 137 | 138 | }); 139 | 140 | // Tests SyncedCron.remove in the process 141 | Tinytest.add('SyncedCron.add starts by it self when running', function(test) { 142 | SyncedCron._reset(); 143 | 144 | test.equal(SyncedCron._collection.find().count(), 0); 145 | test.equal(SyncedCron.running, false); 146 | Log._intercept(2); 147 | 148 | SyncedCron.start(); 149 | 150 | test.equal(SyncedCron.running, true); 151 | 152 | // addd 1 entries 153 | SyncedCron.add(TestEntry); 154 | 155 | test.equal(_.keys(SyncedCron._entries).length, 1); 156 | 157 | SyncedCron.stop(); 158 | 159 | var intercepted = Log._intercepted(); 160 | test.equal(intercepted.length, 2); 161 | 162 | test.equal(SyncedCron.running, false); 163 | test.equal(_.keys(SyncedCron._entries).length, 0); 164 | }); 165 | 166 | Tinytest.add('SyncedCron.config can customize the options object', function(test) { 167 | SyncedCron._reset(); 168 | 169 | SyncedCron.config({ 170 | log: false, 171 | collectionName: 'foo', 172 | utc: true, 173 | collectionTTL: 0 174 | }); 175 | 176 | test.equal(SyncedCron.options.log, false); 177 | test.equal(SyncedCron.options.collectionName, 'foo'); 178 | test.equal(SyncedCron.options.utc, true); 179 | test.equal(SyncedCron.options.collectionTTL, 0); 180 | }); 181 | 182 | Tinytest.addAsync('SyncedCron can log to injected logger', function(test, done) { 183 | SyncedCron._reset(); 184 | 185 | var logger = function() { 186 | test.isTrue(true); 187 | 188 | SyncedCron.stop(); 189 | done(); 190 | }; 191 | 192 | SyncedCron.options.logger = logger; 193 | 194 | SyncedCron.add(TestEntry); 195 | SyncedCron.start(); 196 | 197 | SyncedCron.options.logger = null; 198 | }); 199 | 200 | Tinytest.addAsync('SyncedCron should pass correct arguments to logger', function(test, done) { 201 | SyncedCron._reset(); 202 | 203 | var logger = function(opts) { 204 | test.include(opts, 'level'); 205 | test.include(opts, 'message'); 206 | test.include(opts, 'tag'); 207 | test.equal(opts.tag, 'SyncedCron'); 208 | 209 | SyncedCron.stop(); 210 | done(); 211 | }; 212 | 213 | SyncedCron.options.logger = logger; 214 | 215 | SyncedCron.add(TestEntry); 216 | SyncedCron.start(); 217 | 218 | SyncedCron.options.logger = null; 219 | 220 | }); 221 | 222 | Tinytest.add('Single time schedules don\'t break', function(test) { 223 | // create a once off date 1 sec in the future 224 | var date = new Date(new Date().valueOf() + 1 * 1000); 225 | var schedule = Later.parse.recur().on(date).fullDate(); 226 | 227 | // this would throw without our patch for #41 228 | SyncedCron._laterSetTimeout(_.identity, schedule); 229 | }); 230 | 231 | 232 | Tinytest.add('Do not persist when flag is set to false', function (test) { 233 | SyncedCron._reset(); 234 | 235 | var testEntryNoPersist = _.extend({}, TestEntry, {persist: false}); 236 | 237 | SyncedCron.add(testEntryNoPersist); 238 | 239 | const now = new Date(); 240 | SyncedCron._entryWrapper(testEntryNoPersist)(now); 241 | test.equal(SyncedCron._collection.find().count(), 0); 242 | }); -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": [ 3 | [ 4 | "application-configuration", 5 | "1.0.1" 6 | ], 7 | [ 8 | "binary-heap", 9 | "1.0.0" 10 | ], 11 | [ 12 | "callback-hook", 13 | "1.0.0" 14 | ], 15 | [ 16 | "check", 17 | "1.0.0" 18 | ], 19 | [ 20 | "ddp", 21 | "1.0.8" 22 | ], 23 | [ 24 | "ejson", 25 | "1.0.1" 26 | ], 27 | [ 28 | "follower-livedata", 29 | "1.0.1" 30 | ], 31 | [ 32 | "geojson-utils", 33 | "1.0.0" 34 | ], 35 | [ 36 | "id-map", 37 | "1.0.0" 38 | ], 39 | [ 40 | "json", 41 | "1.0.0" 42 | ], 43 | [ 44 | "logging", 45 | "1.0.2" 46 | ], 47 | [ 48 | "meteor", 49 | "1.0.3" 50 | ], 51 | [ 52 | "minimongo", 53 | "1.0.2" 54 | ], 55 | [ 56 | "mongo", 57 | "1.0.4" 58 | ], 59 | [ 60 | "ordered-dict", 61 | "1.0.0" 62 | ], 63 | [ 64 | "random", 65 | "1.0.0" 66 | ], 67 | [ 68 | "retry", 69 | "1.0.0" 70 | ], 71 | [ 72 | "tracker", 73 | "1.0.2" 74 | ], 75 | [ 76 | "underscore", 77 | "1.0.0" 78 | ] 79 | ], 80 | "pluginDependencies": [], 81 | "toolVersion": "meteor-tool@1.0.28", 82 | "format": "1.0" 83 | } --------------------------------------------------------------------------------