schedule.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657
  1. 'use strict';
  2. /*
  3. node-schedule
  4. A cron-like and not-cron-like job scheduler for Node.
  5. */
  6. var events = require('events'),
  7. util = require('util'),
  8. cronParser = require('cron-parser'),
  9. CronDate = require('cron-parser/lib/date'),
  10. lt = require('long-timeout'),
  11. sorted = require('sorted-array-functions');
  12. /* Job object */
  13. var anonJobCounter = 0;
  14. var scheduledJobs = {};
  15. function isValidDate(date) {
  16. // Taken from http://stackoverflow.com/a/12372720/1562178
  17. // If getTime() returns NaN it'll return false anyway
  18. return date.getTime() === date.getTime();
  19. }
  20. function Job(name, job, callback) {
  21. // setup a private pendingInvocations variable
  22. var pendingInvocations = [];
  23. //setup a private number of invocations variable
  24. var triggeredJobs = 0;
  25. // Set scope vars
  26. var jobName = name && typeof name === 'string' ? name : '<Anonymous Job ' + (++anonJobCounter) + '>';
  27. this.job = name && typeof name === 'function' ? name : job;
  28. // Make sure callback is actually a callback
  29. if (this.job === name) {
  30. // Name wasn't provided and maybe a callback is there
  31. this.callback = typeof job === 'function' ? job : false;
  32. } else {
  33. // Name was provided, and maybe a callback is there
  34. this.callback = typeof callback === 'function' ? callback : false;
  35. }
  36. // Check for generator
  37. if (typeof this.job === 'function' &&
  38. this.job.prototype &&
  39. this.job.prototype.next) {
  40. this.job = function() {
  41. return this.next().value;
  42. }.bind(this.job.call(this));
  43. }
  44. // define properties
  45. Object.defineProperty(this, 'name', {
  46. value: jobName,
  47. writable: false,
  48. enumerable: true
  49. });
  50. // method that require private access
  51. this.trackInvocation = function(invocation) {
  52. // add to our invocation list
  53. sorted.add(pendingInvocations, invocation, sorter);
  54. return true;
  55. };
  56. this.stopTrackingInvocation = function(invocation) {
  57. var invIdx = pendingInvocations.indexOf(invocation);
  58. if (invIdx > -1) {
  59. pendingInvocations.splice(invIdx, 1);
  60. return true;
  61. }
  62. return false;
  63. };
  64. this.triggeredJobs = function() {
  65. return triggeredJobs;
  66. };
  67. this.setTriggeredJobs = function(triggeredJob) {
  68. triggeredJobs = triggeredJob;
  69. };
  70. this.cancel = function(reschedule) {
  71. reschedule = (typeof reschedule == 'boolean') ? reschedule : false;
  72. var inv, newInv;
  73. var newInvs = [];
  74. for (var j = 0; j < pendingInvocations.length; j++) {
  75. inv = pendingInvocations[j];
  76. cancelInvocation(inv);
  77. if (reschedule && (inv.recurrenceRule.recurs || inv.recurrenceRule.next)) {
  78. newInv = scheduleNextRecurrence(inv.recurrenceRule, this, inv.fireDate, inv.endDate);
  79. if (newInv !== null) {
  80. newInvs.push(newInv);
  81. }
  82. }
  83. }
  84. pendingInvocations = [];
  85. for (var k = 0; k < newInvs.length; k++) {
  86. this.trackInvocation(newInvs[k]);
  87. }
  88. // remove from scheduledJobs if reschedule === false
  89. if (!reschedule) {
  90. if (this.name) {
  91. delete scheduledJobs[this.name];
  92. }
  93. }
  94. return true;
  95. };
  96. this.cancelNext = function(reschedule) {
  97. reschedule = (typeof reschedule == 'boolean') ? reschedule : true;
  98. if (!pendingInvocations.length) {
  99. return false;
  100. }
  101. var newInv;
  102. var nextInv = pendingInvocations.shift();
  103. cancelInvocation(nextInv);
  104. if (reschedule && (nextInv.recurrenceRule.recurs || nextInv.recurrenceRule.next)) {
  105. newInv = scheduleNextRecurrence(nextInv.recurrenceRule, this, nextInv.fireDate, nextInv.endDate);
  106. if (newInv !== null) {
  107. this.trackInvocation(newInv);
  108. }
  109. }
  110. return true;
  111. };
  112. this.reschedule = function(spec) {
  113. var inv;
  114. var cInvs = pendingInvocations.slice();
  115. for (var j = 0; j < cInvs.length; j++) {
  116. inv = cInvs[j];
  117. cancelInvocation(inv);
  118. }
  119. pendingInvocations = [];
  120. if (this.schedule(spec)) {
  121. this.setTriggeredJobs(0);
  122. return true;
  123. } else {
  124. pendingInvocations = cInvs;
  125. return false;
  126. }
  127. };
  128. this.nextInvocation = function() {
  129. if (!pendingInvocations.length) {
  130. return null;
  131. }
  132. return pendingInvocations[0].fireDate;
  133. };
  134. this.pendingInvocations = function() {
  135. return pendingInvocations;
  136. };
  137. }
  138. util.inherits(Job, events.EventEmitter);
  139. Job.prototype.invoke = function(fireDate) {
  140. if (typeof this.job == 'function') {
  141. this.setTriggeredJobs(this.triggeredJobs() + 1);
  142. this.job(fireDate);
  143. } else {
  144. this.job.execute(fireDate);
  145. }
  146. };
  147. Job.prototype.runOnDate = function(date) {
  148. return this.schedule(date);
  149. };
  150. Job.prototype.schedule = function(spec) {
  151. var self = this;
  152. var success = false;
  153. var inv;
  154. var start;
  155. var end;
  156. var tz;
  157. // save passed-in value before 'spec' is replaced
  158. if (typeof spec === 'object' && 'tz' in spec) {
  159. tz = spec.tz;
  160. }
  161. if (typeof spec === 'object' && spec.rule) {
  162. start = spec.start || undefined;
  163. end = spec.end || undefined;
  164. spec = spec.rule;
  165. if (start) {
  166. if (!(start instanceof Date)) {
  167. start = new Date(start);
  168. }
  169. start = new CronDate(start, tz);
  170. if (!isValidDate(start) || start.getTime() < Date.now()) {
  171. start = undefined;
  172. }
  173. }
  174. if (end && !(end instanceof Date) && !isValidDate(end = new Date(end))) {
  175. end = undefined;
  176. }
  177. if (end) {
  178. end = new CronDate(end, tz);
  179. }
  180. }
  181. try {
  182. var res = cronParser.parseExpression(spec, { currentDate: start, tz: tz });
  183. inv = scheduleNextRecurrence(res, self, start, end);
  184. if (inv !== null) {
  185. success = self.trackInvocation(inv);
  186. }
  187. } catch (err) {
  188. var type = typeof spec;
  189. if ((type === 'string') || (type === 'number')) {
  190. spec = new Date(spec);
  191. }
  192. if ((spec instanceof Date) && (isValidDate(spec))) {
  193. spec = new CronDate(spec);
  194. if (spec.getTime() >= Date.now()) {
  195. inv = new Invocation(self, spec);
  196. scheduleInvocation(inv);
  197. success = self.trackInvocation(inv);
  198. }
  199. } else if (type === 'object') {
  200. if (!(spec instanceof RecurrenceRule)) {
  201. var r = new RecurrenceRule();
  202. if ('year' in spec) {
  203. r.year = spec.year;
  204. }
  205. if ('month' in spec) {
  206. r.month = spec.month;
  207. }
  208. if ('date' in spec) {
  209. r.date = spec.date;
  210. }
  211. if ('dayOfWeek' in spec) {
  212. r.dayOfWeek = spec.dayOfWeek;
  213. }
  214. if ('hour' in spec) {
  215. r.hour = spec.hour;
  216. }
  217. if ('minute' in spec) {
  218. r.minute = spec.minute;
  219. }
  220. if ('second' in spec) {
  221. r.second = spec.second;
  222. }
  223. spec = r;
  224. }
  225. spec.tz = tz;
  226. inv = scheduleNextRecurrence(spec, self, start, end);
  227. if (inv !== null) {
  228. success = self.trackInvocation(inv);
  229. }
  230. }
  231. }
  232. scheduledJobs[this.name] = this;
  233. return success;
  234. };
  235. /* API
  236. invoke()
  237. runOnDate(date)
  238. schedule(date || recurrenceRule || cronstring)
  239. cancel(reschedule = false)
  240. cancelNext(reschedule = true)
  241. Property constraints
  242. name: readonly
  243. job: readwrite
  244. */
  245. /* DoesntRecur rule */
  246. var DoesntRecur = new RecurrenceRule();
  247. DoesntRecur.recurs = false;
  248. /* Invocation object */
  249. function Invocation(job, fireDate, recurrenceRule, endDate) {
  250. this.job = job;
  251. this.fireDate = fireDate;
  252. this.endDate = endDate;
  253. this.recurrenceRule = recurrenceRule || DoesntRecur;
  254. this.timerID = null;
  255. }
  256. function sorter(a, b) {
  257. return (a.fireDate.getTime() - b.fireDate.getTime());
  258. }
  259. /* Range object */
  260. function Range(start, end, step) {
  261. this.start = start || 0;
  262. this.end = end || 60;
  263. this.step = step || 1;
  264. }
  265. Range.prototype.contains = function(val) {
  266. if (this.step === null || this.step === 1) {
  267. return (val >= this.start && val <= this.end);
  268. } else {
  269. for (var i = this.start; i < this.end; i += this.step) {
  270. if (i === val) {
  271. return true;
  272. }
  273. }
  274. return false;
  275. }
  276. };
  277. /* RecurrenceRule object */
  278. /*
  279. Interpreting each property:
  280. null - any value is valid
  281. number - fixed value
  282. Range - value must fall in range
  283. array - value must validate against any item in list
  284. NOTE: Cron months are 1-based, but RecurrenceRule months are 0-based.
  285. */
  286. function RecurrenceRule(year, month, date, dayOfWeek, hour, minute, second) {
  287. this.recurs = true;
  288. this.year = (year == null) ? null : year;
  289. this.month = (month == null) ? null : month;
  290. this.date = (date == null) ? null : date;
  291. this.dayOfWeek = (dayOfWeek == null) ? null : dayOfWeek;
  292. this.hour = (hour == null) ? null : hour;
  293. this.minute = (minute == null) ? null : minute;
  294. this.second = (second == null) ? 0 : second;
  295. }
  296. RecurrenceRule.prototype.isValid = function() {
  297. function isValidType(num) {
  298. if (Array.isArray(num) || (num instanceof Array)) {
  299. return num.every(function(e) {
  300. return isValidType(e);
  301. });
  302. }
  303. return !(Number.isNaN(Number(num)) && !(num instanceof Range));
  304. }
  305. if (this.month !== null && (this.month < 0 || this.month > 11 || !isValidType(this.month))) {
  306. return false;
  307. }
  308. if (this.dayOfWeek !== null && (this.dayOfWeek < 0 || this.dayOfWeek > 6 || !isValidType(this.dayOfWeek))) {
  309. return false;
  310. }
  311. if (this.hour !== null && (this.hour < 0 || this.hour > 23 || !isValidType(this.hour))) {
  312. return false;
  313. }
  314. if (this.minute !== null && (this.minute < 0 || this.minute > 59 || !isValidType(this.minute))) {
  315. return false;
  316. }
  317. if (this.second !== null && (this.second < 0 || this.second > 59 || !isValidType(this.second))) {
  318. return false;
  319. }
  320. if (this.date !== null) {
  321. if(!isValidType(this.date)) {
  322. return false;
  323. }
  324. switch (this.month) {
  325. case 3:
  326. case 5:
  327. case 8:
  328. case 10:
  329. if (this.date < 1 || this. date > 30) {
  330. return false;
  331. }
  332. break;
  333. case 1:
  334. if (this.date < 1 || this. date > 29) {
  335. return false;
  336. }
  337. break;
  338. default:
  339. if (this.date < 1 || this. date > 31) {
  340. return false;
  341. }
  342. }
  343. }
  344. return true;
  345. };
  346. RecurrenceRule.prototype.nextInvocationDate = function(base) {
  347. var next = this._nextInvocationDate(base);
  348. return next ? next.toDate() : null;
  349. };
  350. RecurrenceRule.prototype._nextInvocationDate = function(base) {
  351. base = ((base instanceof CronDate) || (base instanceof Date)) ? base : (new Date());
  352. if (!this.recurs) {
  353. return null;
  354. }
  355. if(!this.isValid()) {
  356. return null;
  357. }
  358. var now = new CronDate(Date.now(), this.tz);
  359. var fullYear = now.getFullYear();
  360. if ((this.year !== null) &&
  361. (typeof this.year == 'number') &&
  362. (this.year < fullYear)) {
  363. return null;
  364. }
  365. var next = new CronDate(base.getTime(), this.tz);
  366. next.addSecond();
  367. while (true) {
  368. if (this.year !== null) {
  369. fullYear = next.getFullYear();
  370. if ((typeof this.year == 'number') && (this.year < fullYear)) {
  371. next = null;
  372. break;
  373. }
  374. if (!recurMatch(fullYear, this.year)) {
  375. next.addYear();
  376. next.setMonth(0);
  377. next.setDate(1);
  378. next.setHours(0);
  379. next.setMinutes(0);
  380. next.setSeconds(0);
  381. continue;
  382. }
  383. }
  384. if (this.month != null && !recurMatch(next.getMonth(), this.month)) {
  385. next.addMonth();
  386. continue;
  387. }
  388. if (this.date != null && !recurMatch(next.getDate(), this.date)) {
  389. next.addDay();
  390. continue;
  391. }
  392. if (this.dayOfWeek != null && !recurMatch(next.getDay(), this.dayOfWeek)) {
  393. next.addDay();
  394. continue;
  395. }
  396. if (this.hour != null && !recurMatch(next.getHours(), this.hour)) {
  397. next.addHour();
  398. continue;
  399. }
  400. if (this.minute != null && !recurMatch(next.getMinutes(), this.minute)) {
  401. next.addMinute();
  402. continue;
  403. }
  404. if (this.second != null && !recurMatch(next.getSeconds(), this.second)) {
  405. next.addSecond();
  406. continue;
  407. }
  408. break;
  409. }
  410. return next;
  411. };
  412. function recurMatch(val, matcher) {
  413. if (matcher == null) {
  414. return true;
  415. }
  416. if (typeof matcher === 'number') {
  417. return (val === matcher);
  418. } else if(typeof matcher === 'string') {
  419. return (val === Number(matcher));
  420. } else if (matcher instanceof Range) {
  421. return matcher.contains(val);
  422. } else if (Array.isArray(matcher) || (matcher instanceof Array)) {
  423. for (var i = 0; i < matcher.length; i++) {
  424. if (recurMatch(val, matcher[i])) {
  425. return true;
  426. }
  427. }
  428. }
  429. return false;
  430. }
  431. /* Date-based scheduler */
  432. function runOnDate(date, job) {
  433. var now = Date.now();
  434. var then = date.getTime();
  435. return lt.setTimeout(function() {
  436. if (then > Date.now())
  437. runOnDate(date, job);
  438. else
  439. job();
  440. }, (then < now ? 0 : then - now));
  441. }
  442. var invocations = [];
  443. var currentInvocation = null;
  444. function scheduleInvocation(invocation) {
  445. sorted.add(invocations, invocation, sorter);
  446. prepareNextInvocation();
  447. var date = invocation.fireDate instanceof CronDate ? invocation.fireDate.toDate() : invocation.fireDate;
  448. invocation.job.emit('scheduled', date);
  449. }
  450. function prepareNextInvocation() {
  451. if (invocations.length > 0 && currentInvocation !== invocations[0]) {
  452. if (currentInvocation !== null) {
  453. lt.clearTimeout(currentInvocation.timerID);
  454. currentInvocation.timerID = null;
  455. currentInvocation = null;
  456. }
  457. currentInvocation = invocations[0];
  458. var job = currentInvocation.job;
  459. var cinv = currentInvocation;
  460. currentInvocation.timerID = runOnDate(currentInvocation.fireDate, function() {
  461. currentInvocationFinished();
  462. if (job.callback) {
  463. job.callback();
  464. }
  465. if (cinv.recurrenceRule.recurs || cinv.recurrenceRule._endDate === null) {
  466. var inv = scheduleNextRecurrence(cinv.recurrenceRule, cinv.job, cinv.fireDate, cinv.endDate);
  467. if (inv !== null) {
  468. inv.job.trackInvocation(inv);
  469. }
  470. }
  471. job.stopTrackingInvocation(cinv);
  472. job.invoke(cinv.fireDate instanceof CronDate ? cinv.fireDate.toDate() : cinv.fireDate);
  473. job.emit('run');
  474. });
  475. }
  476. }
  477. function currentInvocationFinished() {
  478. invocations.shift();
  479. currentInvocation = null;
  480. prepareNextInvocation();
  481. }
  482. function cancelInvocation(invocation) {
  483. var idx = invocations.indexOf(invocation);
  484. if (idx > -1) {
  485. invocations.splice(idx, 1);
  486. if (invocation.timerID !== null) {
  487. lt.clearTimeout(invocation.timerID);
  488. }
  489. if (currentInvocation === invocation) {
  490. currentInvocation = null;
  491. }
  492. invocation.job.emit('canceled', invocation.fireDate);
  493. prepareNextInvocation();
  494. }
  495. }
  496. /* Recurrence scheduler */
  497. function scheduleNextRecurrence(rule, job, prevDate, endDate) {
  498. prevDate = (prevDate instanceof CronDate) ? prevDate : new CronDate();
  499. var date = (rule instanceof RecurrenceRule) ? rule._nextInvocationDate(prevDate) : rule.next();
  500. if (date === null) {
  501. return null;
  502. }
  503. if ((endDate instanceof CronDate) && date.getTime() > endDate.getTime()) {
  504. return null;
  505. }
  506. var inv = new Invocation(job, date, rule, endDate);
  507. scheduleInvocation(inv);
  508. return inv;
  509. }
  510. /* Convenience methods */
  511. function scheduleJob() {
  512. if (arguments.length < 2) {
  513. return null;
  514. }
  515. var name = (arguments.length >= 3 && typeof arguments[0] === 'string') ? arguments[0] : null;
  516. var spec = name ? arguments[1] : arguments[0];
  517. var method = name ? arguments[2] : arguments[1];
  518. var callback = name ? arguments[3] : arguments[2];
  519. var job = new Job(name, method, callback);
  520. if (job.schedule(spec)) {
  521. return job;
  522. }
  523. return null;
  524. }
  525. function rescheduleJob(job, spec) {
  526. if (job instanceof Job) {
  527. if (job.reschedule(spec)) {
  528. return job;
  529. }
  530. } else if (typeof job == 'string' || job instanceof String) {
  531. if (job in scheduledJobs && scheduledJobs.hasOwnProperty(job)) {
  532. if (scheduledJobs[job].reschedule(spec)) {
  533. return scheduledJobs[job];
  534. }
  535. }
  536. }
  537. return null;
  538. }
  539. function cancelJob(job) {
  540. var success = false;
  541. if (job instanceof Job) {
  542. success = job.cancel();
  543. } else if (typeof job == 'string' || job instanceof String) {
  544. if (job in scheduledJobs && scheduledJobs.hasOwnProperty(job)) {
  545. success = scheduledJobs[job].cancel();
  546. }
  547. }
  548. return success;
  549. }
  550. /* Public API */
  551. module.exports.Job = Job;
  552. module.exports.Range = Range;
  553. module.exports.RecurrenceRule = RecurrenceRule;
  554. module.exports.Invocation = Invocation;
  555. module.exports.scheduleJob = scheduleJob;
  556. module.exports.rescheduleJob = rescheduleJob;
  557. module.exports.scheduledJobs = scheduledJobs;
  558. module.exports.cancelJob = cancelJob;