feat: cron (#34)

This commit is contained in:
Julien Fontanet 2018-02-01 11:28:16 +01:00 committed by GitHub
parent ff2c69102d
commit 348bc16d6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 673 additions and 11 deletions

2
.gitignore vendored
View File

@ -3,6 +3,8 @@
/lerna-debug.log /lerna-debug.log
/lerna-debug.log.* /lerna-debug.log.*
/@xen-orchestra/*/dist/
/@xen-orchestra/*/node_modules/
/packages/*/dist/ /packages/*/dist/
/packages/*/node_modules/ /packages/*/node_modules/

View File

@ -0,0 +1,41 @@
'use strict'
const NODE_ENV = process.env.NODE_ENV || 'development'
const __PROD__ = NODE_ENV === 'production'
const __TEST__ = NODE_ENV === 'test'
const pkg = require('./package')
let nodeCompat = (pkg.engines || {}).node
if (nodeCompat === undefined) {
nodeCompat = '6'
} else {
const trimChars = '^=>~'
while (trimChars.includes(nodeCompat[0])) {
nodeCompat = nodeCompat.slice(1)
}
}
module.exports = {
comments: !__PROD__,
ignore: __TEST__ ? undefined : [/\.spec\.js$/],
plugins: ['lodash'],
presets: [
[
'@babel/env',
{
debug: !__TEST__,
loose: true,
shippedProposals: true,
targets: __PROD__
? {
browsers: '>2%',
node: nodeCompat,
}
: { node: 'current' },
useBuiltIns: '@babel/polyfill' in (pkg.dependencies || {}) && 'usage',
},
],
'@babel/flow',
],
}

View File

@ -0,0 +1,24 @@
/benchmark/
/benchmarks/
*.bench.js
*.bench.js.map
/examples/
example.js
example.js.map
*.example.js
*.example.js.map
/fixture/
/fixtures/
*.fixture.js
*.fixture.js.map
*.fixtures.js
*.fixtures.js.map
/test/
/tests/
*.spec.js
*.spec.js.map
__snapshots__/

View File

@ -0,0 +1,92 @@
# @xen-orchestra/cron [![Build Status](https://travis-ci.org/vatesfr/xen-orchestra.png?branch=master)](https://travis-ci.org/vatesfr/xen-orchestra)
> Focused, well maintained, cron parser/scheduler
## Install
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/cron):
```
> npm install --save @xen-orchestra/cron
```
## Usage
```js
import * as Cron from '@xen-orchestra/cron'
Cron.parse('* * * jan,mar *')
// → { month: [ 1, 3 ] }
Cron.next('* * * jan,mar *', 2, 'America/New_York')
// → [ 2018-01-19T22:15:00.000Z, 2018-01-19T22:16:00.000Z ]
const stop = Cron.schedule('@hourly', () => {
console.log(new Date())
}, 'UTC+05:30')
```
### Pattern syntax
```
<minute> <hour> <day of month> <month> <day of week>
```
Each entry can be:
- a single value
- a range (`0-23` or `*/2`)
- a list of values/ranges (`1,8-12`)
A wildcard (`*`) can be used as a shortcut for the whole range
(`first-last`).
Step values can be used in conjunctions with ranges. For instance,
`1-7/2` is the same as `1,3,5,7`.
| Field | Allowed values |
|------------------|----------------|
| minute | 0-59 |
| hour | 0-23 |
| day of the month | 1-31 or 3-letter names (`jan`, `feb`, …) |
| month | 0-11 |
| day of week | 0-7 (0 and 7 both mean Sunday) or 3-letter names (`mon`, `tue`, …) |
> Note: the month range is 0-11 to be compatible with
> [cron](https://github.com/kelektiv/node-cron), it does not appear to
> be very standard though.
## Development
```
# Install dependencies
> yarn
# Run the tests
> yarn test
# Continuously compile
> yarn dev
# Continuously run the tests
> yarn dev-test
# Build for production (automatically called by npm install)
> yarn build
```
## Contributions
Contributions are *very* welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xo-web/issues)
you've encountered;
- fork and create a pull request.
## License
ISC © [Vates SAS](https://vates.fr)

View File

@ -0,0 +1,57 @@
{
"private": true,
"name": "@xen-orchestra/cron",
"version": "0.0.0",
"license": "ISC",
"description": "Focused, well maintained, cron parser/scheduler",
"keywords": [
"cron",
"cronjob",
"crontab",
"job",
"parser",
"pattern",
"schedule",
"scheduling",
"task"
],
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/@xen-orchestra/cron",
"bugs": "https://github.com/vatesfr/xo-web/issues",
"repository": {
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Julien Fontanet",
"email": "julien.fontanet@isonoe.net"
},
"preferGlobal": false,
"main": "dist/",
"bin": {},
"files": [
"dist/"
],
"engines": {
"node": ">=6"
},
"dependencies": {
"lodash": "^4.17.4",
"luxon": "^0.4.0"
},
"devDependencies": {
"@babel/cli": "7.0.0-beta.38",
"@babel/core": "7.0.0-beta.38",
"@babel/preset-env": "7.0.0-beta.38",
"@babel/preset-flow": "7.0.0-beta.38",
"cross-env": "^5.1.3",
"rimraf": "^2.6.2"
},
"scripts": {
"build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/",
"clean": "rimraf dist/",
"dev": "cross-env NODE_ENV=development babel --watch --source-maps --out-dir=dist/ src/",
"prebuild": "yarn run clean",
"predev": "yarn run clean",
"prepublishOnly": "yarn run build"
}
}

View File

@ -0,0 +1,40 @@
import { DateTime } from 'luxon'
import nextDate from './next'
import parse from './parse'
const autoParse = pattern =>
typeof pattern === 'string' ? parse(pattern) : schedule
export const next = (cronPattern, n = 1, zone = 'utc') => {
const schedule = autoParse(cronPattern)
let date = DateTime.fromObject({ zone })
const dates = []
for (let i = 0; i < n; ++i) {
dates.push((date = nextDate(schedule, date)).toJSDate())
}
return dates
}
export { parse }
export const schedule = (cronPattern, fn, zone = 'utc') => {
const wrapper = () => {
fn()
scheduleNextRun()
}
let handle
const schedule = autoParse(cronPattern)
const scheduleNextRun = () => {
const now = DateTime.fromObject({ zone })
const nextRun = next(schedule, now)
handle = setTimeout(wrapper, nextRun - now)
}
scheduleNextRun()
return () => {
clearTimeout(handle)
}
}

View File

@ -0,0 +1,90 @@
import sortedIndex from 'lodash/sortedIndex'
import { DateTime } from 'luxon'
const NEXT_MAPPING = {
month: { year: 1 },
day: { month: 1 },
weekday: { week: 1 },
hour: { day: 1 },
minute: { hour: 1 },
}
const getFirst = values => values !== undefined ? values[0] : 0
const setFirstAvailable = (date, unit, values) => {
if (values === undefined) {
return date
}
const curr = date.get(unit)
const next = values[sortedIndex(values, curr) % values.length]
if (curr === next) {
return date
}
const newDate = date.set({ [unit]: next })
return newDate > date ? newDate : newDate.plus(NEXT_MAPPING[unit])
}
// returns the next run, after the passed date
export default (schedule, fromDate) => {
let date = fromDate
.set({
second: 0,
millisecond: 0,
})
.plus({ minute: 1 })
const { minute, hour, dayOfMonth, month, dayOfWeek } = schedule
date = setFirstAvailable(date, 'minute', minute)
let tmp
tmp = setFirstAvailable(date, 'hour', hour)
if (tmp !== date) {
date = tmp.set({
minute: getFirst(minute),
})
}
let loop
let i = 0
do {
loop = false
tmp = setFirstAvailable(date, 'month', month)
if (tmp !== date) {
date = tmp.set({
day: 1,
hour: getFirst(hour),
minute: getFirst(minute),
})
}
if (dayOfMonth === undefined) {
if (dayOfWeek !== undefined) {
tmp = setFirstAvailable(date, 'weekday', dayOfWeek)
}
} else if (dayOfWeek === undefined) {
tmp = setFirstAvailable(date, 'day', dayOfMonth)
} else {
tmp = DateTime.min(
setFirstAvailable(date, 'day', dayOfMonth),
setFirstAvailable(date, 'weekday', dayOfWeek)
)
}
if (tmp !== date) {
loop = tmp.month !== date.month
date = tmp.set({
hour: getFirst(hour),
minute: getFirst(minute),
})
}
} while (loop && ++i < 5)
if (loop) {
throw new Error('no solutions found for this schedule')
}
return date
}

View File

@ -0,0 +1,46 @@
/* eslint-env jest */
import mapValues from 'lodash/mapValues'
import { DateTime } from 'luxon'
import next from './next'
import parse from './parse'
const N = (pattern, fromDate = '2018-04-09T06:25') =>
next(parse(pattern), DateTime.fromISO(fromDate, { zone: 'utc' })).toISO({
includeOffset: false,
suppressMilliseconds: true,
suppressSeconds: true,
})
describe('next()', () => {
mapValues(
{
minutely: ['* * * * *', '2018-04-09T06:26'],
hourly: ['@hourly', '2018-04-09T07:00'],
daily: ['@daily', '2018-04-10T00:00'],
monthly: ['@monthly', '2018-05-01T00:00'],
yearly: ['@yearly', '2019-01-01T00:00'],
weekly: ['@weekly', '2018-04-15T00:00'],
},
([pattern, result], title) =>
it(title, () => {
expect(N(pattern)).toBe(result)
})
)
it('select first between month-day and week-day', () => {
expect(N('* * 10 * wen')).toBe('2018-04-10T00:00')
expect(N('* * 12 * wen')).toBe('2018-04-11T00:00')
})
it('select the last available day of a month', () => {
expect(N('* * 29 feb *')).toBe('2020-02-29T00:00')
})
it('fails when no solutions has been found', () => {
expect(() => N('0 0 30 feb *')).toThrow(
'no solutions found for this schedule'
)
})
})

View File

@ -0,0 +1,198 @@
const compareNumbers = (a, b) => a - b
const createParser = ({ fields: [...fields], presets: { ...presets } }) => {
const m = fields.length
for (let j = 0; j < m; ++j) {
const field = fields[j]
let { aliases } = field
if (aliases !== undefined) {
let symbols = aliases
if (Array.isArray(aliases)) {
aliases = {}
const [start] = field.range
symbols.forEach((alias, i) => {
aliases[alias] = start + i
})
} else {
symbols = Object.keys(aliases)
}
fields[j] = {
...field,
aliases,
aliasesRegExp: new RegExp(symbols.join('|'), 'y'),
}
}
}
let field, i, n, pattern, schedule, values
const isDigit = c => c >= '0' && c <= '9'
const match = c => pattern[i] === c && (++i, true)
const consumeWhitespaces = () => {
let c
while ((c = pattern[i]) === ' ' || c === '\t') {
++i
}
}
const parseInteger = () => {
let c
const digits = []
while (isDigit((c = pattern[i]))) {
++i
digits.push(c)
}
if (digits.length === 0) {
throw new SyntaxError(`${field.name}: missing integer at character ${i}`)
}
return Number.parseInt(digits.join(''), 10)
}
const parseValue = () => {
let value
const { aliasesRegExp } = field
if (aliasesRegExp === undefined || isDigit(pattern[i])) {
value = parseInteger()
const { post } = field
if (post !== undefined) {
value = post(value)
}
} else {
aliasesRegExp.lastIndex = i
const matches = aliasesRegExp.exec(pattern)
if (matches === null) {
throw new SyntaxError(
`${field.name}: missing alias or integer at character ${i}`
)
}
const [alias] = matches
i += alias.length
value = field.aliases[alias]
}
const { range } = field
if (value < range[0] || value > range[1]) {
throw new SyntaxError(
`${field.name}: ${value} is not between ${range[0]} and ${range[1]}`
)
}
return value
}
const parseRange = () => {
let end, start, step
if (match('*')) {
if (!match('/')) {
return
}
[start, end] = field.range
step = parseInteger()
} else {
start = parseValue()
if (!match('-')) {
values.add(start)
return
}
end = parseValue()
step = match('/') ? parseInteger() : 1
}
for (let i = start; i <= end; i += step) {
values.add(i)
}
}
const parseSequence = () => {
do {
parseRange()
} while (match(','))
}
const parse = p => {
{
const schedule = presets[p]
if (schedule !== undefined) {
return typeof schedule === 'string'
? (presets[p] = parse(schedule))
: schedule
}
}
try {
i = 0
n = p.length
pattern = p
schedule = {}
for (let j = 0; j < m; ++j) {
consumeWhitespaces()
field = fields[j]
values = new Set()
parseSequence()
if (values.size !== 0) {
schedule[field.name] = Array.from(values).sort(compareNumbers)
}
}
consumeWhitespaces()
if (i !== n) {
throw new SyntaxError(
`unexpected character at offset ${i}, expected end`
)
}
return schedule
} finally {
field = pattern = schedule = values = undefined
}
}
return parse
}
export default createParser({
fields: [
{
name: 'minute',
range: [0, 59],
},
{
name: 'hour',
range: [0, 23],
},
{
name: 'dayOfMonth',
range: [1, 31],
},
{
aliases: 'jan feb mar apr may jun jul aug sep oct nov dec'.split(' '),
name: 'month',
range: [1, 12],
// this function is applied to numeric entries (not steps)
//
// currently parse month 0-11
post: value => value + 1,
},
{
aliases: 'mon tue wen thu fri sat sun'.split(' '),
name: 'dayOfWeek',
post: value => (value === 0 ? 7 : value),
range: [1, 7],
},
],
presets: {
'@annually': '0 0 1 jan *',
'@daily': '0 0 * * *',
'@hourly': '0 * * * *',
'@monthly': '0 0 1 * *',
'@weekly': '0 0 * * sun',
'@yearly': '0 0 1 jan *',
},
})

View File

@ -0,0 +1,51 @@
/* eslint-env jest */
import parse from './parse'
describe('parse()', () => {
it('works', () => {
expect(parse('0 0-10 */10 jan,2,4-11/3 *')).toEqual({
minute: [0],
hour: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
dayOfMonth: [1, 11, 21, 31],
month: [1, 3, 5, 8, 11],
})
})
it('correctly parse months', () => {
expect(parse('* * * 0,11 *')).toEqual({
month: [1, 12],
})
expect(parse('* * * jan,dec *')).toEqual({
month: [1, 12],
})
})
it('correctly parse days', () => {
expect(parse('* * * * mon,sun')).toEqual({
dayOfWeek: [1, 7],
})
})
it('reports missing integer', () => {
expect(() => parse('*/a')).toThrow(
'minute: missing integer at character 2'
)
expect(() => parse('*')).toThrow('hour: missing integer at character 1')
})
it('reports invalid aliases', () => {
expect(() => parse('* * * jan-foo *')).toThrow(
'month: missing alias or integer at character 10'
)
})
it('dayOfWeek: 0 and 7 bind to sunday', () => {
expect(parse('* * * * 0')).toEqual({
dayOfWeek: [7],
})
expect(parse('* * * * 7')).toEqual({
dayOfWeek: [7],
})
})
})

View File

@ -29,9 +29,10 @@
], ],
"testRegex": "\\.spec\\.js$", "testRegex": "\\.spec\\.js$",
"transform": { "transform": {
"complex-matcher/.+\\.jsx?$": "babel-7-jest", "/@xen-orchestra/cron/.+\\.jsx?$": "babel-7-jest",
"value-matcher/.+\\.jsx?$": "babel-7-jest", "/packages/complex-matcher/.+\\.jsx?$": "babel-7-jest",
"xo-cli/.+\\.jsx?$": "babel-7-jest", "/packages/value-matcher/.+\\.jsx?$": "babel-7-jest",
"/packages/xo-cli/.+\\.jsx?$": "babel-7-jest",
"\\.jsx?$": "babel-jest" "\\.jsx?$": "babel-jest"
} }
}, },
@ -57,6 +58,7 @@
"test": "jest && flow status" "test": "jest && flow status"
}, },
"workspaces": [ "workspaces": [
"@xen-orchestra/*",
"packages/*" "packages/*"
] ]
} }

View File

@ -1,16 +1,25 @@
const { forEach, fromCallback } = require('promise-toolbox') const { forEach, fromCallback } = require('promise-toolbox')
const fs = require('fs') const fs = require('fs')
const PKGS_DIR = `${__dirname}/../packages` const ROOT_DIR = `${__dirname}/..`
const _getPackages = scope => {
const inScope = scope !== undefined
const dir = `${ROOT_DIR}/${inScope ? scope : 'packages'}`
return fromCallback(cb => fs.readdir(dir, cb)).then(names =>
names.map(name => ({
dir: `${dir}/${name}`,
name: inScope ? `${scope}/${name}` : name,
}))
)
}
exports.getPackages = (readPackageJson = false) => { exports.getPackages = (readPackageJson = false) => {
const p = fromCallback(cb => const p = Promise.all([
fs.readdir(PKGS_DIR, cb) _getPackages(),
).then(names => { _getPackages('@xen-orchestra'),
const pkgs = names.map(name => ({ ]).then(pkgs => {
dir: `${PKGS_DIR}/${name}`, pkgs = [].concat(...pkgs) // flatten
name,
}))
return readPackageJson return readPackageJson
? Promise.all(pkgs.map(pkg => ? Promise.all(pkgs.map(pkg =>
readFile(`${pkg.dir}/package.json`).then(data => { readFile(`${pkg.dir}/package.json`).then(data => {

View File

@ -2681,6 +2681,10 @@ fstream@^1.0.0, fstream@^1.0.10, fstream@^1.0.2:
mkdirp ">=0.5 0" mkdirp ">=0.5 0"
rimraf "2" rimraf "2"
full-icu@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/full-icu/-/full-icu-1.2.0.tgz#6bd8bf565f696aab30df503de2d47b92d9f1046f"
function-bind@^1.0.2, function-bind@^1.1.1: function-bind@^1.0.2, function-bind@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@ -4182,6 +4186,12 @@ ltx@^2.5.0, ltx@^2.6.2:
dependencies: dependencies:
inherits "^2.0.1" inherits "^2.0.1"
luxon@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/luxon/-/luxon-0.4.0.tgz#e7146ce52a6db7e82448438827860092d75bb8d6"
dependencies:
full-icu "^1.2.0"
make-error@^1.0.2, make-error@^1.0.4, make-error@^1.2.1, make-error@^1.2.3, make-error@^1.3.0: make-error@^1.0.2, make-error@^1.0.4, make-error@^1.2.1, make-error@^1.2.3, make-error@^1.3.0:
version "1.3.2" version "1.3.2"
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.2.tgz#8762ffad2444dd8ff1f7c819629fa28e24fea1c4" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.2.tgz#8762ffad2444dd8ff1f7c819629fa28e24fea1c4"