├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── LICENSE.md ├── README.md ├── __tests__ ├── helpers.ts ├── parseCron.test.ts ├── runner.test.ts └── upsertSchedule.test.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── sql ├── 000001.sql └── 000002.sql ├── src ├── index.ts ├── lib.ts ├── migrate.ts ├── parseCron.ts ├── runner.ts └── upsertSchedule.ts ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", 3 | extends: [ 4 | "eslint:recommended", 5 | "prettier/@typescript-eslint", 6 | "plugin:prettier/recommended", 7 | ], 8 | plugins: ["jest", "@typescript-eslint", "prettier"], 9 | parserOptions: { 10 | ecmaVersion: 2018, 11 | sourceType: "module", 12 | }, 13 | env: { 14 | node: true, 15 | jest: true, 16 | es6: true 17 | }, 18 | rules: { 19 | "@typescript-eslint/no-unused-vars": [ 20 | "error", 21 | { 22 | argsIgnorePattern: "^_", 23 | varsIgnorePattern: "^_", 24 | args: "after-used", 25 | ignoreRestSiblings: true 26 | } 27 | ], 28 | "curly": "error", 29 | "no-else-return": 0, 30 | "no-return-assign": [2, "except-parens"], 31 | "no-underscore-dangle": 0, 32 | "jest/no-focused-tests": 2, 33 | "jest/no-identical-title": 2, 34 | camelcase: 0, 35 | "prefer-arrow-callback": [ 36 | "error", 37 | { 38 | allowNamedFunctions: true 39 | } 40 | ], 41 | "class-methods-use-this": 0, 42 | "no-restricted-syntax": 0, 43 | "no-param-reassign": [ 44 | "error", 45 | { 46 | props: false 47 | } 48 | ], 49 | 50 | "arrow-body-style": 0, 51 | "no-nested-ternary": 0, 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CI: true 7 | PGUSER: postgres 8 | PGPASSWORD: postgres 9 | PGHOST: "127.0.0.1" 10 | PGPORT: 5432 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-18.04 15 | 16 | strategy: 17 | matrix: 18 | node-version: [10.x, 12.x, 14.x] 19 | postgres-version: [9.6, 10, 11, 12] 20 | 21 | services: 22 | postgres: 23 | image: postgres:${{ matrix.postgres-version }} 24 | env: 25 | POSTGRES_USER: postgres 26 | POSTGRES_PASSWORD: postgres 27 | POSTGRES_DB: graphile_scheduler_test 28 | ports: 29 | - "0.0.0.0:5432:5432" 30 | # needed because the postgres container does not provide a healthcheck 31 | options: 32 | --health-cmd pg_isready --health-interval 10s --health-timeout 5s 33 | --health-retries 5 34 | 35 | steps: 36 | - uses: actions/checkout@v1 37 | - name: Use Node.js ${{ matrix.node-version }} 38 | uses: actions/setup-node@v1 39 | with: 40 | node-version: ${{ matrix.node-version }} 41 | - run: yarn --frozen-lockfile 42 | - run: yarn lint 43 | - run: yarn jest -i --ci 44 | 45 | altschema: 46 | runs-on: ubuntu-18.04 47 | env: 48 | GRAPHILE_WORKER_SCHEMA: custom_schema 49 | 50 | services: 51 | postgres: 52 | image: postgres:11 53 | env: 54 | POSTGRES_USER: postgres 55 | POSTGRES_PASSWORD: postgres 56 | POSTGRES_DB: graphile_scheduler_test 57 | ports: 58 | - "0.0.0.0:5432:5432" 59 | # needed because the postgres container does not provide a healthcheck 60 | options: 61 | --health-cmd pg_isready --health-interval 10s --health-timeout 5s 62 | --health-retries 5 63 | 64 | steps: 65 | - uses: actions/checkout@v1 66 | - name: Use Node.js ${{ matrix.node-version }} 67 | uses: actions/setup-node@v1 68 | with: 69 | node-version: 12.x 70 | - run: yarn --frozen-lockfile 71 | - run: yarn lint 72 | - run: yarn jest -i --ci 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | npm-debug.log* 3 | yarn-debug.log* 4 | yarn-error.log* 5 | .vscode 6 | node_modules/ 7 | /tasks/ 8 | .DS_Store 9 | .idea 10 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v10.16.3 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright © `2020` David Beck 4 | 5 | Permission is hereby granted, free of charge, to any person 6 | obtaining a copy of this software and associated documentation 7 | files (the “Software”), to deal in the Software without 8 | restriction, including without limitation the rights to use, 9 | copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following 12 | conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 19 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 21 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 22 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 24 | OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Graphile Worker now has support for scheduling built in. And it's really good! You should use that instead. 2 | 3 | # graphile-scheduler 4 | 5 | Reliable job scheduling for PostgreSQL running on Node.js built on top of [graphile-worker](https://github.com/graphile/worker). Allows you to run jobs on a regular schedule (e.g. sending reminders once a week, cleaning up data nightly, etc) with fault tolerance and resilience. Can be used with any PostgreSQL-backed application. Pairs beautifully with PostGraphile. 6 | 7 | Why not use something like node-cron or a timer? These in process approaches have 2 downsides: 1) you have to make sure that there is only 1 process running the schedule or you risk running the tasks multiple times and 2) if that 1 process happens to be down (either due to an outage or a deploy) when the scheduled job is suppose to be excecuted, it will be skipped. 8 | 9 | graphile-scheduler keeps track of it's schedules in a PostgreSQL database. On startup it will queue any jobs that should have occurred since the last time it was checked, whether that was 1 minute ago or 1 day ago (up to a certain limit determined by your configuration). It uses PostgreSQL locking so it's safe to have multipel schedulers running at once. And because it is integrated with graphile-worker, jobs are queued and automatically retried with exponential backoff if they fail. 10 | 11 | ## Quickstart 12 | 13 | ### Add the scheduler to your project: 14 | 15 | ```sh 16 | yarn add graphile-scheduler 17 | # or: npm install --save graphile-scheduler 18 | ``` 19 | 20 | ### Schedule and run jobs: 21 | 22 | ```js 23 | run({ 24 | connectionString: "postgres:///", 25 | schedules: [ 26 | { 27 | name: "send_reminder", 28 | pattern: "0 10 * * 1-5", // every weekday at 10AM 29 | timeZone: "America/Los_Angeles", 30 | task: async ({ fireDate }) => { 31 | console.log("send a reminder for", fireDate); 32 | }, 33 | }, 34 | ], 35 | }); 36 | ``` 37 | 38 | Every weekday at 10AM the task function will be called. You can use `fireDate` to access the time the job was originally suppose to be run. If the scheduler goes down and retroactively queues schedules that it missed or the job is retried, this will be when it should have been queued. 39 | 40 | ### Schedule and run jobs separately: 41 | 42 | If you provide a task function an instance of graphile-worker will also be started to run the task. However you can have the runner only schedule jobs and have a separate process actually run them off of the queue: 43 | 44 | ```js 45 | run({ 46 | connectionString: "postgres:///", 47 | schedules: [ 48 | { 49 | name: "send_reminder", 50 | pattern: "0 10 * * 1-5", // every weekday at 10AM 51 | timeZone: "America/Los_Angeles", 52 | }, 53 | ], 54 | }); 55 | ``` 56 | 57 | Or you can create schedules manually in the database and only run schedule checks: 58 | 59 | ```sql 60 | INSERT INTO "graphile_scheduler"."schedules" 61 | ( 62 | "schedule_name", 63 | "minute", 64 | "hour", 65 | "day", 66 | "month", 67 | "dow", 68 | "timezone", 69 | "task_identifier" 70 | ) 71 | VALUES( 72 | 'foo', 73 | '{0}', 74 | '{10}', 75 | graphile_scheduler.every_day(), 76 | graphile_scheduler.every_month(), 77 | '{1,2,3,4,5}', 78 | 'America/Los_Angeles', 79 | 'foo' 80 | ); 81 | ``` 82 | 83 | ```js 84 | run({ 85 | connectionString: "postgres:///", 86 | schedules: ["send_reminder"], 87 | }); 88 | ``` 89 | 90 | If you omit `schedules` completely, every schedule in the database will be checked. 91 | 92 | ```js 93 | run({ connectionString: "postgres:///" }); 94 | ``` 95 | 96 | ## Status 97 | 98 | This project is feature complete and in use, but has not yet been battle tested and test coverage is incomplete. It is possilbe that there will be breaking changes leading up to a 1.0.0 release. 99 | 100 | Additionally, graphile-worker is still in early development as well, so until it reaches 1.0.0 the project pins it's dependency to it at a fixed version number. 101 | -------------------------------------------------------------------------------- /__tests__/helpers.ts: -------------------------------------------------------------------------------- 1 | import * as pg from "pg"; 2 | import { migrate } from "../src/migrate"; 3 | import { runMigrations as runWorkerMigrations } from "graphile-worker"; 4 | import { RunnerOptions } from "../src/runner"; 5 | import { processOptions } from "../src/lib"; 6 | 7 | export const TEST_CONNECTION_STRING = 8 | process.env.TEST_CONNECTION_STRING || "graphile_scheduler_test"; 9 | 10 | export async function withPgPool( 11 | cb: (pool: pg.Pool) => Promise 12 | ): Promise { 13 | const pool = new pg.Pool({ 14 | connectionString: TEST_CONNECTION_STRING, 15 | }); 16 | try { 17 | return await cb(pool); 18 | } finally { 19 | pool.end(); 20 | } 21 | } 22 | 23 | export async function withPgClient( 24 | cb: (client: pg.PoolClient) => Promise 25 | ): Promise { 26 | return withPgPool(async pool => { 27 | const client = await pool.connect(); 28 | try { 29 | return await cb(client); 30 | } finally { 31 | client.release(); 32 | } 33 | }); 34 | } 35 | 36 | export async function withTransaction( 37 | cb: (client: pg.PoolClient) => Promise, 38 | closeCommand = "rollback" 39 | ): Promise { 40 | return withPgClient(async client => { 41 | await client.query("begin"); 42 | try { 43 | return await cb(client); 44 | } finally { 45 | await client.query(closeCommand); 46 | } 47 | }); 48 | } 49 | 50 | export async function reset(options: RunnerOptions, pgPool: pg.Pool) { 51 | const { 52 | workerSchema, 53 | escapedWorkerSchema, 54 | escapedSchedulerSchema, 55 | } = processOptions(options); 56 | 57 | await pgPool.query(`drop schema if exists ${escapedWorkerSchema} cascade;`); 58 | await pgPool.query( 59 | `drop schema if exists ${escapedSchedulerSchema} cascade;` 60 | ); 61 | 62 | await runWorkerMigrations({ pgPool, schema: workerSchema }); 63 | 64 | const client = await pgPool.connect(); 65 | try { 66 | await migrate(options, client); 67 | } finally { 68 | await client.release(); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /__tests__/parseCron.test.ts: -------------------------------------------------------------------------------- 1 | import parseCron from "../src/parseCron"; 2 | 3 | describe(parseCron, () => { 4 | it("parses single values", () => { 5 | const result = parseCron("0 10 1 6 5"); 6 | 7 | expect(result).toEqual({ 8 | minute: [0], 9 | hour: [10], 10 | day: [1], 11 | month: [6], 12 | dow: [5], 13 | }); 14 | }); 15 | 16 | it("converts single month names", () => { 17 | const result = parseCron("0 10 1 June 5"); 18 | 19 | expect(result).toEqual({ 20 | minute: [0], 21 | hour: [10], 22 | day: [1], 23 | month: [6], 24 | dow: [5], 25 | }); 26 | }); 27 | 28 | it("converts ranges of month names", () => { 29 | const result = parseCron("0 10 1 jun-aug 5"); 30 | 31 | expect(result).toEqual({ 32 | minute: [0], 33 | hour: [10], 34 | day: [1], 35 | month: [6, 7, 8], 36 | dow: [5], 37 | }); 38 | }); 39 | 40 | it("converts single weekday names", () => { 41 | const result = parseCron("0 10 1 6 Friday"); 42 | 43 | expect(result).toEqual({ 44 | minute: [0], 45 | hour: [10], 46 | day: [1], 47 | month: [6], 48 | dow: [5], 49 | }); 50 | }); 51 | 52 | it("converts ranges of weekday names", () => { 53 | const result = parseCron("0 10 1 6 mon-fri"); 54 | 55 | expect(result).toEqual({ 56 | minute: [0], 57 | hour: [10], 58 | day: [1], 59 | month: [6], 60 | dow: [1, 2, 3, 4, 5], 61 | }); 62 | }); 63 | 64 | it("parse multiple values", () => { 65 | const result = parseCron("0 10 1,15,30 6,8 5"); 66 | 67 | expect(result).toEqual({ 68 | minute: [0], 69 | hour: [10], 70 | day: [1, 15, 30], 71 | month: [6, 8], 72 | dow: [5], 73 | }); 74 | }); 75 | 76 | it("parse ranges", () => { 77 | const result = parseCron("0 10 1 6 1-5"); 78 | 79 | expect(result).toEqual({ 80 | minute: [0], 81 | hour: [10], 82 | day: [1], 83 | month: [6], 84 | dow: [1, 2, 3, 4, 5], 85 | }); 86 | }); 87 | 88 | it("parse ranges and individual values", () => { 89 | const result = parseCron("0 10,14-16 1 6 5"); 90 | 91 | expect(result).toEqual({ 92 | minute: [0], 93 | hour: [10, 14, 15, 16], 94 | day: [1], 95 | month: [6], 96 | dow: [5], 97 | }); 98 | }); 99 | 100 | it("parse minute wildcards", () => { 101 | const result = parseCron("* 10 1 6 5"); 102 | 103 | expect(result).toEqual({ 104 | minute: new Array(60).fill(0).map((_, i) => i), 105 | hour: [10], 106 | day: [1], 107 | month: [6], 108 | dow: [5], 109 | }); 110 | }); 111 | 112 | it("parse hour wildcards", () => { 113 | const result = parseCron("0 * 1 6 5"); 114 | 115 | expect(result).toEqual({ 116 | minute: [0], 117 | hour: [ 118 | 0, 119 | 1, 120 | 2, 121 | 3, 122 | 4, 123 | 5, 124 | 6, 125 | 7, 126 | 8, 127 | 9, 128 | 10, 129 | 11, 130 | 12, 131 | 13, 132 | 14, 133 | 15, 134 | 16, 135 | 17, 136 | 18, 137 | 19, 138 | 20, 139 | 21, 140 | 22, 141 | 23, 142 | ], 143 | day: [1], 144 | month: [6], 145 | dow: [5], 146 | }); 147 | }); 148 | 149 | it("parse day wildcards", () => { 150 | const result = parseCron("0 10 * 6 5"); 151 | 152 | expect(result).toEqual({ 153 | minute: [0], 154 | hour: [10], 155 | day: new Array(31).fill(0).map((_, i) => i + 1), 156 | month: [6], 157 | dow: [5], 158 | }); 159 | }); 160 | 161 | it("parse month wildcards", () => { 162 | const result = parseCron("0 10 1 * 5"); 163 | 164 | expect(result).toEqual({ 165 | minute: [0], 166 | hour: [10], 167 | day: [1], 168 | month: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], 169 | dow: [5], 170 | }); 171 | }); 172 | 173 | it("parse weekday wildcards", () => { 174 | const result = parseCron("0 10 1 6 *"); 175 | 176 | expect(result).toEqual({ 177 | minute: [0], 178 | hour: [10], 179 | day: [1], 180 | month: [6], 181 | dow: [0, 1, 2, 3, 4, 5, 6], 182 | }); 183 | }); 184 | 185 | it("parse steps", () => { 186 | const result = parseCron("0 10 1 */2 5"); 187 | 188 | expect(result).toEqual({ 189 | minute: [0], 190 | hour: [10], 191 | day: [1], 192 | month: [2, 4, 6, 8, 10, 12], 193 | dow: [5], 194 | }); 195 | }); 196 | 197 | it("parse steps between", () => { 198 | const result = parseCron("0 10-18/2 1 6 5"); 199 | 200 | expect(result).toEqual({ 201 | minute: [0], 202 | hour: [10, 12, 14, 16, 18], 203 | day: [1], 204 | month: [6], 205 | dow: [5], 206 | }); 207 | }); 208 | 209 | it("normalizes sunday", () => { 210 | const result = parseCron("0 10 1 6 7"); 211 | 212 | expect(result).toEqual({ 213 | minute: [0], 214 | hour: [10], 215 | day: [1], 216 | month: [6], 217 | dow: [0], 218 | }); 219 | }); 220 | }); 221 | -------------------------------------------------------------------------------- /__tests__/runner.test.ts: -------------------------------------------------------------------------------- 1 | import { runOnce } from "../src/runner"; 2 | import { reset, withPgPool } from "./helpers"; 3 | import * as sinon from "sinon"; 4 | import * as moment from "moment"; 5 | import { processOptions } from "../src/lib"; 6 | 7 | describe("runner", () => { 8 | let clock = sinon.useFakeTimers(); 9 | 10 | beforeEach(() => { 11 | clock = sinon.useFakeTimers(moment("2020-01-15 14:00:30").toDate()); 12 | }); 13 | 14 | afterEach(() => { 15 | clock.restore(); 16 | }); 17 | 18 | it("queues upcoming jobs", () => 19 | withPgPool(async pgPool => { 20 | const { escapedWorkerSchema, escapedSchedulerSchema } = processOptions(); 21 | 22 | await reset({}, pgPool); 23 | await pgPool.query(` 24 | INSERT INTO ${escapedSchedulerSchema}."schedules" 25 | ("schedule_name", "last_checked", "minute", "hour", "task_identifier") 26 | VALUES('test', '2020-01-15 14:00:00', '{1}', '{14}', 'test'); 27 | `); 28 | 29 | await runOnce({ pgPool }); 30 | 31 | const { rows: jobs } = await pgPool.query( 32 | `SELECT * FROM ${escapedWorkerSchema}.jobs;` 33 | ); 34 | expect(jobs).toHaveLength(1); 35 | expect(jobs[0].task_identifier).toEqual("test"); 36 | moment(jobs[0].payload.fireDate).isSame(moment("2020-01-15 14:00:00")); 37 | expect( 38 | moment(jobs[0].payload.fireDate).isSame(moment("2020-01-15 14:01:00")) 39 | ).toBeTruthy(); 40 | expect( 41 | moment(jobs[0].run_at).isSame(moment("2020-01-15 14:01:00")) 42 | ).toBeTruthy(); 43 | })); 44 | }); 45 | -------------------------------------------------------------------------------- /__tests__/upsertSchedule.test.ts: -------------------------------------------------------------------------------- 1 | import upsertSchedule from "../src/upsertSchedule"; 2 | import { RunnerOptions } from "../src"; 3 | 4 | describe(upsertSchedule, () => { 5 | const runnerOptions: RunnerOptions = { 6 | schedulerSchema: "graphile_scheduler", 7 | }; 8 | 9 | it("generates sql", () => { 10 | const [sql, args] = upsertSchedule( 11 | { 12 | name: "something_new", 13 | pattern: "0 10 1 6 5", 14 | timeZone: "America/New_York", 15 | 16 | taskIdentifier: "something_new_task", 17 | queueName: "something_new_queue", 18 | maxAttempts: 20, 19 | }, 20 | runnerOptions, 21 | { inlineValues: true } 22 | ); 23 | 24 | expect(sql.replace(/\s/g, "")).toEqual( 25 | ` 26 | INSERT INTO "${runnerOptions.schedulerSchema}"."schedules" 27 | (minute, hour, day, month, dow, schedule_name, timezone, task_identifier, queue_name, max_attempts) 28 | VALUES ( 29 | '{0}', 30 | '{10}', 31 | '{1}', 32 | '{6}', 33 | '{5}', 34 | 'something_new', 35 | 'America/New_York', 36 | 'something_new_task', 37 | 'something_new_queue', 38 | 20 39 | ) 40 | ON CONFLICT (schedule_name) DO UPDATE 41 | SET minute = EXCLUDED.minute, 42 | hour = EXCLUDED.hour, 43 | day = EXCLUDED.day, 44 | month = EXCLUDED.month, 45 | dow = EXCLUDED.dow, 46 | timezone = EXCLUDED.timezone, 47 | task_identifier = EXCLUDED.task_identifier, 48 | queue_name = EXCLUDED.queue_name, 49 | max_attempts = EXCLUDED.max_attempts; 50 | `.replace(/\s/g, "") 51 | ); 52 | 53 | expect(args).toHaveLength(0); 54 | }); 55 | 56 | it("generates sql with placeholders", () => { 57 | const [sql, args] = upsertSchedule( 58 | { 59 | name: "something_new", 60 | pattern: "0 10 1 6 5", 61 | timeZone: "America/New_York", 62 | 63 | taskIdentifier: "something_new_task", 64 | queueName: "something_new_queue", 65 | maxAttempts: 20, 66 | }, 67 | runnerOptions 68 | ); 69 | 70 | expect(sql.replace(/\s/g, "")).toEqual( 71 | ` 72 | INSERT INTO "${runnerOptions.schedulerSchema}"."schedules" 73 | (minute, hour, day, month, dow, schedule_name, timezone, task_identifier, queue_name, max_attempts) 74 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) 75 | ON CONFLICT (schedule_name) DO UPDATE 76 | SET minute = EXCLUDED.minute, 77 | hour = EXCLUDED.hour, 78 | day = EXCLUDED.day, 79 | month = EXCLUDED.month, 80 | dow = EXCLUDED.dow, 81 | timezone = EXCLUDED.timezone, 82 | task_identifier = EXCLUDED.task_identifier, 83 | queue_name = EXCLUDED.queue_name, 84 | max_attempts = EXCLUDED.max_attempts; 85 | `.replace(/\s/g, "") 86 | ); 87 | 88 | expect(args).toHaveLength(10); 89 | }); 90 | 91 | it("generates sql defaulting taskIdentifier", () => { 92 | const args = upsertSchedule( 93 | { 94 | name: "something_new", 95 | pattern: "0 10 1 6 5", 96 | timeZone: "America/New_York", 97 | 98 | queueName: "something_new_queue", 99 | maxAttempts: 20, 100 | }, 101 | runnerOptions 102 | )[1]; 103 | 104 | expect(args).toHaveLength(10); 105 | expect(args[7]).toEqual("something_new"); 106 | }); 107 | 108 | it("generates sql inlining defaults", () => { 109 | const [sql, args] = upsertSchedule( 110 | { 111 | name: "something_new", 112 | pattern: "0 10 1 6 5", 113 | timeZone: "America/New_York", 114 | }, 115 | runnerOptions, 116 | { inlineValues: true } 117 | ); 118 | 119 | expect(sql.replace(/\s/g, "")).toEqual( 120 | ` 121 | INSERT INTO "${runnerOptions.schedulerSchema}"."schedules" 122 | (minute, hour, day, month, dow, schedule_name, timezone, task_identifier, queue_name, max_attempts) 123 | VALUES ( 124 | '{0}', 125 | '{10}', 126 | '{1}', 127 | '{6}', 128 | '{5}', 129 | 'something_new', 130 | 'America/New_York', 131 | 'something_new', 132 | DEFAULT, 133 | DEFAULT 134 | ) 135 | ON CONFLICT (schedule_name) DO UPDATE 136 | SET minute = EXCLUDED.minute, 137 | hour = EXCLUDED.hour, 138 | day = EXCLUDED.day, 139 | month = EXCLUDED.month, 140 | dow = EXCLUDED.dow, 141 | timezone = EXCLUDED.timezone, 142 | task_identifier = EXCLUDED.task_identifier, 143 | queue_name = EXCLUDED.queue_name, 144 | max_attempts = EXCLUDED.max_attempts; 145 | `.replace(/\s/g, "") 146 | ); 147 | 148 | expect(args).toHaveLength(0); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/src", "/__tests__"], 3 | transform: { 4 | "^.+\\.tsx?$": "ts-jest", 5 | }, 6 | testRegex: "(/__tests__/.*\\.(test|spec))\\.[tj]sx?$", 7 | moduleFileExtensions: ["ts", "js", "json"], 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphile-scheduler", 3 | "version": "0.8.0", 4 | "description": "Job scheduler for PostgreSQL", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "prepack": "rm -Rf dist && tsc", 9 | "watch": "mkdir -p dist && tsc --watch", 10 | "lint": "eslint 'src/**/*'", 11 | "test": "createdb graphile_scheduler_test || true && jest -i" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/davbeck/graphile-scheduler.git" 16 | }, 17 | "keywords": [ 18 | "postgresql", 19 | "postgres", 20 | "pg", 21 | "scheduler", 22 | "sql", 23 | "easy", 24 | "fast", 25 | "jobs", 26 | "tasks" 27 | ], 28 | "author": "David Beck ", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/davbeck/graphile-scheduler/issues" 32 | }, 33 | "homepage": "https://github.com/davbeck/graphile-scheduler#readme", 34 | "dependencies": { 35 | "@types/debug": "^4.1.2", 36 | "@types/jest": "^24.0.11", 37 | "@types/pg": "^7.14.7", 38 | "graphile-worker": "~0.8.1", 39 | "moment": "^2.24.0", 40 | "pg": ">=6.5 <9", 41 | "pg-connection-string": "^2.0.0", 42 | "tslib": "^1.9.3", 43 | "yargs": "^15.1.0" 44 | }, 45 | "devDependencies": { 46 | "@types/sinon": "^7.5.2", 47 | "@types/yargs": "^15.0.0", 48 | "@typescript-eslint/eslint-plugin": "^2.5.0", 49 | "@typescript-eslint/parser": "^2.5.0", 50 | "eslint": "^6.5.1", 51 | "eslint-config-prettier": "^6.4.0", 52 | "eslint-plugin-jest": "^22.20.0", 53 | "eslint-plugin-prettier": "^3.0.1", 54 | "eslint_d": "^8.0.0", 55 | "jest": "^24.5.0", 56 | "prettier": "^1.16.4", 57 | "sinon": "^9.0.0", 58 | "ts-jest": "^24.0.0", 59 | "tsc-watch": "^4.0.0", 60 | "typescript": "^3.3.3333" 61 | }, 62 | "files": [ 63 | "dist", 64 | "sql" 65 | ], 66 | "engines": { 67 | "node": ">=10.0.0" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /sql/000001.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION :GRAPHILE_SCHEDULER_SCHEMA.every_minute() RETURNS integer[] AS $$ 2 | SELECT ARRAY_AGG(range) FROM GENERATE_SERIES(0,59) AS range; 3 | $$ LANGUAGE SQL; 4 | 5 | CREATE OR REPLACE FUNCTION :GRAPHILE_SCHEDULER_SCHEMA.every_hour() RETURNS integer[] AS $$ 6 | SELECT ARRAY_AGG(range) FROM GENERATE_SERIES(0,23) AS range; 7 | $$ LANGUAGE SQL; 8 | 9 | CREATE OR REPLACE FUNCTION :GRAPHILE_SCHEDULER_SCHEMA.every_day() RETURNS integer[] AS $$ 10 | SELECT ARRAY_AGG(range) FROM GENERATE_SERIES(1,31) AS range; 11 | $$ LANGUAGE SQL; 12 | 13 | CREATE OR REPLACE FUNCTION :GRAPHILE_SCHEDULER_SCHEMA.every_month() RETURNS integer[] AS $$ 14 | SELECT ARRAY_AGG(range) FROM GENERATE_SERIES(1,12) AS range; 15 | $$ LANGUAGE SQL; 16 | 17 | CREATE OR REPLACE FUNCTION :GRAPHILE_SCHEDULER_SCHEMA.every_dow() RETURNS integer[] AS $$ 18 | SELECT ARRAY_AGG(range) FROM GENERATE_SERIES(0,7) AS range; 19 | $$ LANGUAGE SQL; 20 | 21 | 22 | -- Keep updated_at up to date 23 | create function :GRAPHILE_SCHEDULER_SCHEMA.tg__update_timestamp() returns trigger as $$ 24 | begin 25 | new.updated_at = greatest(now(), old.updated_at + interval '1 millisecond'); 26 | return new; 27 | end; 28 | $$ language plpgsql; 29 | 30 | 31 | CREATE TABLE IF NOT EXISTS :GRAPHILE_SCHEDULER_SCHEMA."schedules" ( 32 | "schedule_name" text, 33 | "last_checked" timestamp with time zone NOT NULL DEFAULT now(), 34 | 35 | "minute" integer[] NOT NULL DEFAULT :GRAPHILE_SCHEDULER_SCHEMA.every_minute(), 36 | "hour" integer[] NOT NULL DEFAULT :GRAPHILE_SCHEDULER_SCHEMA.every_hour(), 37 | "day" integer[] NOT NULL DEFAULT :GRAPHILE_SCHEDULER_SCHEMA.every_day(), 38 | "month" integer[] NOT NULL DEFAULT :GRAPHILE_SCHEDULER_SCHEMA.every_month(), 39 | "dow" integer[] NOT NULL DEFAULT :GRAPHILE_SCHEDULER_SCHEMA.every_dow(), 40 | "timezone" TEXT NOT NULL CHECK (NOW() AT TIME ZONE timezone IS NOT NULL) DEFAULT current_setting('TIMEZONE'), 41 | 42 | "task_identifier" text NOT NULL, 43 | "queue_name" text DEFAULT (public.gen_random_uuid())::text NOT NULL, 44 | "max_attempts" integer NOT NULL DEFAULT '25', 45 | "created_at" timestamp with time zone NOT NULL DEFAULT now(), 46 | "updated_at" timestamp with time zone NOT NULL DEFAULT now(), 47 | PRIMARY KEY ("schedule_name") 48 | ); 49 | ALTER TABLE :GRAPHILE_SCHEDULER_SCHEMA."schedules" ENABLE ROW LEVEL SECURITY; 50 | CREATE TRIGGER _100_timestamps BEFORE UPDATE ON :GRAPHILE_SCHEDULER_SCHEMA."schedules" FOR EACH ROW EXECUTE PROCEDURE :GRAPHILE_SCHEDULER_SCHEMA.tg__update_timestamp(); 51 | 52 | 53 | CREATE OR REPLACE FUNCTION :GRAPHILE_SCHEDULER_SCHEMA.schedules_matches(schedule :GRAPHILE_SCHEDULER_SCHEMA.schedules, check_time TIMESTAMP WITH TIME ZONE = NOW()) 54 | RETURNS BOOLEAN 55 | AS $$ 56 | SELECT EXTRACT(minute FROM check_time) = ANY(schedule.minute) 57 | AND EXTRACT(hour FROM check_time) = ANY(schedule.hour) 58 | AND EXTRACT(day FROM check_time) = ANY(schedule.day) 59 | AND EXTRACT(month FROM check_time) = ANY(schedule.month) 60 | AND EXTRACT(dow FROM check_time) = ANY(schedule.dow); 61 | $$ LANGUAGE sql; 62 | 63 | 64 | CREATE OR REPLACE FUNCTION :GRAPHILE_SCHEDULER_SCHEMA.check_schedule(schedule_names text[] = NULL, starting_At TIMESTAMP WITH TIME ZONE = NULL, until TIMESTAMP WITH TIME ZONE = NOW()) 65 | RETURNS :GRAPHILE_SCHEDULER_SCHEMA.schedules 66 | AS $$ 67 | DECLARE 68 | v_schedule :GRAPHILE_SCHEDULER_SCHEMA.schedules; 69 | v_next_check TIMESTAMP WITH TIME ZONE; 70 | BEGIN 71 | SELECT * INTO v_schedule 72 | FROM :GRAPHILE_SCHEDULER_SCHEMA.schedules 73 | WHERE last_checked < until 74 | AND (schedule_names IS NULL OR schedule_name = any(schedule_names)) 75 | ORDER BY last_checked ASC 76 | LIMIT 1 77 | FOR UPDATE OF schedules 78 | SKIP LOCKED; 79 | 80 | IF v_schedule IS NULL THEN 81 | RETURN NULL; 82 | END IF; 83 | 84 | v_next_check := greatest(starting_At, v_schedule.last_checked); 85 | 86 | LOOP 87 | v_next_check := v_next_check + interval '1 minute'; 88 | 89 | IF :GRAPHILE_SCHEDULER_SCHEMA.schedules_matches(v_schedule, v_next_check) THEN 90 | PERFORM :GRAPHILE_WORKER_SCHEMA.add_job( 91 | identifier := v_schedule.task_identifier, 92 | payload := json_build_object('fireDate', date_trunc('minute', v_next_check)), 93 | queue_name := v_schedule.queue_name, 94 | run_at := date_trunc('minute', v_next_check), 95 | max_attempts := v_schedule.max_attempts 96 | ); 97 | END IF; 98 | 99 | EXIT WHEN v_next_check > until; 100 | END LOOP ; 101 | 102 | UPDATE :GRAPHILE_SCHEDULER_SCHEMA.schedules 103 | SET last_checked = v_next_check 104 | WHERE schedule_name = v_schedule.schedule_name 105 | RETURNING * INTO v_schedule; 106 | 107 | RETURN v_schedule; 108 | END; 109 | $$ LANGUAGE plpgsql; -------------------------------------------------------------------------------- /sql/000002.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION :GRAPHILE_SCHEDULER_SCHEMA.schedules_matches(schedule :GRAPHILE_SCHEDULER_SCHEMA.schedules, check_time TIMESTAMP WITH TIME ZONE = NOW()) 2 | RETURNS BOOLEAN 3 | AS $$ 4 | SELECT EXTRACT(minute FROM check_time AT TIME ZONE schedule.timezone) = ANY(schedule.minute) 5 | AND EXTRACT(hour FROM check_time AT TIME ZONE schedule.timezone) = ANY(schedule.hour) 6 | AND EXTRACT(day FROM check_time AT TIME ZONE schedule.timezone) = ANY(schedule.day) 7 | AND EXTRACT(month FROM check_time AT TIME ZONE schedule.timezone) = ANY(schedule.month) 8 | AND EXTRACT(dow FROM check_time AT TIME ZONE schedule.timezone) = ANY(schedule.dow); 9 | $$ LANGUAGE sql; 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { run, runOnce, Runner, RunnerOptions, ScheduleConfig } from "./runner"; 2 | -------------------------------------------------------------------------------- /src/lib.ts: -------------------------------------------------------------------------------- 1 | import { RunnerOptions } from "./runner"; 2 | import { Client } from "pg"; 3 | 4 | export interface CompiledOptions extends RunnerOptions { 5 | workerSchema: string; 6 | escapedWorkerSchema: string; 7 | } 8 | 9 | const defaults = { 10 | workerSchema: process.env.GRAPHILE_WORKER_SCHEMA ?? "graphile_worker", 11 | schedulerSchema: 12 | process.env.GRAPHILE_SCHEDULER_SCHEMA ?? "graphile_scheduler", 13 | }; 14 | 15 | export function processOptions(options: RunnerOptions = {}) { 16 | return { 17 | ...defaults, 18 | ...options, 19 | 20 | escapedWorkerSchema: Client.prototype.escapeIdentifier( 21 | options.workerSchema ?? defaults.workerSchema 22 | ), 23 | escapedSchedulerSchema: Client.prototype.escapeIdentifier( 24 | options.schedulerSchema ?? defaults.schedulerSchema 25 | ), 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/migrate.ts: -------------------------------------------------------------------------------- 1 | import { PoolClient } from "pg"; 2 | import * as fs from "fs"; 3 | import { promisify } from "util"; 4 | import { RunnerOptions } from "./runner"; 5 | import { processOptions } from "./lib"; 6 | 7 | export const readFile = promisify(fs.readFile); 8 | export const readdir = promisify(fs.readdir); 9 | 10 | export async function migrate(options: RunnerOptions, client: PoolClient) { 11 | const { escapedWorkerSchema, escapedSchedulerSchema } = processOptions( 12 | options 13 | ); 14 | 15 | let latestMigration: number | null = null; 16 | try { 17 | const { 18 | rows: [row], 19 | } = await client.query( 20 | `select id from ${escapedSchedulerSchema}.migrations order by id desc limit 1;` 21 | ); 22 | if (row) { 23 | latestMigration = row.id; 24 | } 25 | } catch (e) { 26 | if (e.code === "42P01") { 27 | await client.query(` 28 | create extension if not exists pgcrypto with schema public; 29 | create schema if not exists ${escapedSchedulerSchema}; 30 | create table ${escapedSchedulerSchema}.migrations( 31 | id int primary key, 32 | ts timestamptz default now() not null 33 | ); 34 | `); 35 | } else { 36 | throw e; 37 | } 38 | } 39 | const migrationFiles = (await readdir(`${__dirname}/../sql`)) 40 | .filter(f => f.match(/^[0-9]{6}\.sql$/)) 41 | .sort(); 42 | 43 | for (const migrationFile of migrationFiles) { 44 | const migrationNumber = parseInt(migrationFile.substr(0, 6), 10); 45 | if (latestMigration == null || migrationNumber > latestMigration) { 46 | const rawText = await readFile( 47 | `${__dirname}/../sql/${migrationFile}`, 48 | "utf8" 49 | ); 50 | const text = rawText 51 | .replace(/:GRAPHILE_WORKER_SCHEMA\b/g, escapedWorkerSchema) 52 | .replace(/:GRAPHILE_SCHEDULER_SCHEMA\b/g, escapedSchedulerSchema); 53 | 54 | await client.query("begin"); 55 | try { 56 | await client.query({ 57 | text, 58 | }); 59 | await client.query({ 60 | text: `insert into ${escapedSchedulerSchema}.migrations (id) values ($1)`, 61 | values: [migrationNumber], 62 | }); 63 | await client.query("commit"); 64 | } catch (e) { 65 | await client.query("rollback"); 66 | throw e; 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/parseCron.ts: -------------------------------------------------------------------------------- 1 | const months = { 2 | january: 1, 3 | february: 2, 4 | march: 3, 5 | april: 4, 6 | may: 5, 7 | june: 6, 8 | july: 7, 9 | august: 8, 10 | september: 9, 11 | october: 10, 12 | november: 11, 13 | december: 12, 14 | jan: 1, 15 | feb: 2, 16 | mar: 3, 17 | apr: 4, 18 | jun: 6, 19 | jul: 7, 20 | aug: 8, 21 | sep: 9, 22 | oct: 10, 23 | nov: 11, 24 | dec: 12, 25 | }; 26 | 27 | const weekdays = { 28 | sunday: 0, 29 | monday: 1, 30 | tuesday: 2, 31 | wednesday: 3, 32 | thursday: 4, 33 | friday: 5, 34 | saturday: 6, 35 | sun: 0, 36 | mon: 1, 37 | tue: 2, 38 | wed: 3, 39 | thu: 4, 40 | fri: 5, 41 | sat: 6, 42 | }; 43 | 44 | function convertNames(expression: string, names: { [name: string]: number }) { 45 | let result = expression; 46 | for (const [name, index] of Object.entries(names)) { 47 | result = result.replace(new RegExp(name, "gi"), index.toString()); 48 | } 49 | return result; 50 | } 51 | 52 | const stepValuePattern = /^(.+)\/(\d+)$/; 53 | function convertSteps(expression: string) { 54 | var match = stepValuePattern.exec(expression); 55 | 56 | if (match !== null && match.length > 0) { 57 | var values = match[1].split(","); 58 | const setpValues = []; 59 | const divider = parseInt(match[2], 10); 60 | 61 | for (const valueString of values) { 62 | const value = parseInt(valueString, 10); 63 | if (value % divider === 0) { 64 | setpValues.push(value); 65 | } 66 | } 67 | return setpValues.join(","); 68 | } 69 | 70 | return expression; 71 | } 72 | 73 | function replaceWithRange( 74 | expression: string, 75 | text: string, 76 | init: string, 77 | end: string 78 | ) { 79 | var numbers = []; 80 | var last = parseInt(end); 81 | var first = parseInt(init); 82 | 83 | if (first > last) { 84 | last = parseInt(init); 85 | first = parseInt(end); 86 | } 87 | 88 | for (var i = first; i <= last; i++) { 89 | numbers.push(i); 90 | } 91 | 92 | return expression.replace(new RegExp(text, "gi"), numbers.join()); 93 | } 94 | 95 | function convertRange(expression: string) { 96 | let result = expression; 97 | var rangeRegEx = /(\d+)-(\d+)/; 98 | var match = rangeRegEx.exec(result); 99 | while (match !== null && match.length > 0) { 100 | result = replaceWithRange(result, match[0], match[1], match[2]); 101 | match = rangeRegEx.exec(result); 102 | } 103 | return result; 104 | } 105 | 106 | function expandWildcard(atom: string, expanded: string) { 107 | return atom.replace("*", expanded); 108 | } 109 | 110 | function parseAtom(atom: string) { 111 | return convertSteps(convertRange(atom)) 112 | .split(",") 113 | .map(v => parseInt(v)); 114 | } 115 | 116 | export type CronPattern = { 117 | minute: number[]; 118 | hour: number[]; 119 | day: number[]; 120 | month: number[]; 121 | dow: number[]; 122 | }; 123 | 124 | export default function parseCron(cron: string): CronPattern { 125 | const atoms = cron.split(" "); 126 | if (atoms.length > 5) { 127 | throw new Error(`Invalid cron entry: ${cron}`); 128 | } 129 | 130 | let minute = parseAtom(expandWildcard(atoms[0], "0-59")); 131 | let hour = parseAtom(expandWildcard(atoms[1], "0-23")); 132 | let day = parseAtom(expandWildcard(atoms[2], "1-31")); 133 | let month = parseAtom(expandWildcard(convertNames(atoms[3], months), "1-12")); 134 | let dow = parseAtom( 135 | expandWildcard(convertNames(atoms[4], weekdays), "0-6") 136 | ).map(dow => (dow === 7 ? 0 : dow)); 137 | 138 | return { minute, hour, day, month, dow }; 139 | } 140 | -------------------------------------------------------------------------------- /src/runner.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import * as moment from "moment"; 3 | import { Pool } from "pg"; 4 | import { migrate } from "./migrate"; 5 | import { 6 | Task, 7 | RunnerOptions as WorkerRunnerOptions, 8 | Runner as WorkerRunner, 9 | run as runWorker, 10 | runOnce as runWorkerOnce, 11 | runMigrations as runWorkerMigrations, 12 | consoleLogFactory, 13 | Logger, 14 | } from "graphile-worker"; 15 | import upsertSchedule, { Schedule } from "./upsertSchedule"; 16 | import { processOptions } from "./lib"; 17 | 18 | export interface ScheduleConfig extends Schedule { 19 | task?: Task; 20 | } 21 | 22 | export interface RunnerOptions extends Omit { 23 | checkInterval?: number; 24 | leadTime?: number; 25 | maxAge?: number; 26 | schedules?: (string | ScheduleConfig)[]; 27 | /** 28 | * Which PostgreSQL schema should Graphile Worker use for graphile-worker? Defaults to 'graphile_worker'. 29 | */ 30 | workerSchema?: string; 31 | /** 32 | * Which PostgreSQL schema should Graphile Worker use for graphile-scheduler? Defaults to 'graphile_scheduler'. 33 | */ 34 | schedulerSchema?: string; 35 | } 36 | 37 | export class Runner { 38 | options: RunnerOptions; 39 | workerOptions: WorkerRunnerOptions; 40 | pgPool: Pool; 41 | logger: Logger; 42 | schedules?: (string | ScheduleConfig)[]; 43 | scheduleNames?: string[]; 44 | checkInterval: number; 45 | leadTime: number; 46 | maxAge: number; 47 | 48 | interval: NodeJS.Timeout | null; 49 | workerRunner: WorkerRunner | null; 50 | 51 | constructor(options: RunnerOptions = {}) { 52 | const { 53 | schedules, 54 | pgPool, 55 | connectionString, 56 | checkInterval, 57 | leadTime, 58 | maxAge, 59 | workerSchema, 60 | ...workerOptions 61 | } = options; 62 | this.options = options; 63 | this.checkInterval = checkInterval ?? 60 * 1000; 64 | this.leadTime = leadTime ?? 0; 65 | this.maxAge = maxAge ?? 1000 * 60 * 60 * 24 * 3; 66 | 67 | this.logger = options.logger ?? new Logger(consoleLogFactory); 68 | 69 | assert( 70 | !pgPool || !connectionString, 71 | "Both `pgPool` and `connectionString` are set, at most one of these options should be provided" 72 | ); 73 | 74 | if (pgPool) { 75 | this.pgPool = pgPool; 76 | } else if (connectionString) { 77 | this.pgPool = new Pool({ connectionString: connectionString }); 78 | } else if (process.env.DATABASE_URL) { 79 | this.pgPool = new Pool({ connectionString: process.env.DATABASE_URL }); 80 | } else { 81 | throw new Error( 82 | "You must either specify `pgPool` or `connectionString`, or you must make the `DATABASE_URL` environmental variable available." 83 | ); 84 | } 85 | 86 | this.pgPool.on("error", err => { 87 | /* 88 | * This handler is required so that client connection errors don't bring 89 | * the server down (via `unhandledError`). 90 | * 91 | * `pg` will automatically terminate the client and remove it from the 92 | * pool, so we don't actually need to take any action here, just ensure 93 | * that the event listener is registered. 94 | */ 95 | this.logger.error(`PostgreSQL client generated error: ${err.message}`, { 96 | error: err, 97 | }); 98 | }); 99 | 100 | this.schedules = schedules; 101 | this.scheduleNames = schedules?.map(s => 102 | typeof s === "string" ? s : s.name 103 | ); 104 | 105 | // add in our embed schedule tasks 106 | let taskList = options.taskList ?? {}; 107 | for (const schedule of this.schedules ?? []) { 108 | if (typeof schedule === "object" && schedule.task != null) { 109 | if (options.taskDirectory != null) { 110 | throw new Error( 111 | "You cannot specify `taskDirectory` and `task` in `schedules`." 112 | ); 113 | } 114 | 115 | taskList = { ...taskList, [schedule.name]: schedule.task }; 116 | } 117 | } 118 | 119 | this.workerOptions = { 120 | ...workerOptions, 121 | taskList, 122 | pgPool: this.pgPool, 123 | schema: workerSchema, 124 | }; 125 | } 126 | 127 | get shouldRunWorker(): boolean { 128 | return ( 129 | this.workerOptions.taskDirectory !== undefined || 130 | (this.workerOptions.taskList !== undefined && 131 | Object.keys(this.workerOptions.taskList).length > 0) 132 | ); 133 | } 134 | 135 | async runMigrations() { 136 | await runWorkerMigrations(this.workerOptions); 137 | const client = await this.pgPool.connect(); 138 | 139 | try { 140 | await migrate(this.options, client); 141 | 142 | for (const schedule of this.schedules ?? []) { 143 | if (typeof schedule === "object") { 144 | const [sql, args] = upsertSchedule(schedule, this.options); 145 | await client.query(sql, args); 146 | } 147 | } 148 | } finally { 149 | await client.release(); 150 | } 151 | } 152 | 153 | private async check() { 154 | const { escapedSchedulerSchema } = processOptions(this.options); 155 | const client = await this.pgPool.connect(); 156 | const checkDate = moment().add(this.checkInterval + this.leadTime, "ms"); 157 | const startingAt = moment().subtract(this.maxAge, "ms"); 158 | 159 | this.logger.debug(`running check from ${startingAt} to ${checkDate}`); 160 | try { 161 | let updated: string | null = null; 162 | do { 163 | const res = await client.query( 164 | `SELECT * FROM ${escapedSchedulerSchema}.check_schedule($1, $2, $3)`, 165 | [this.scheduleNames, startingAt, checkDate.toDate()] 166 | ); 167 | updated = res.rows[0].schedule_name; 168 | 169 | if (updated) { 170 | this.logger.debug(`${updated} checked`); 171 | } 172 | } while (updated != null); 173 | } finally { 174 | await client.release(); 175 | } 176 | } 177 | 178 | async runOnce() { 179 | await this.runMigrations(); 180 | 181 | await this.check(); 182 | 183 | if (this.shouldRunWorker) { 184 | await runWorkerOnce(this.workerOptions); 185 | } 186 | } 187 | 188 | async run() { 189 | await this.runMigrations(); 190 | 191 | this.check(); 192 | this.interval = setInterval(async () => { 193 | this.check(); 194 | }, this.checkInterval); 195 | 196 | if (this.shouldRunWorker) { 197 | this.workerRunner = await runWorker(this.workerOptions); 198 | } 199 | } 200 | 201 | async stop() { 202 | if (this.interval) { 203 | clearInterval(this.interval); 204 | this.interval = null; 205 | 206 | await this.workerRunner?.stop(); 207 | this.workerRunner = null; 208 | } else { 209 | throw new Error("Runner is already stopped"); 210 | } 211 | } 212 | } 213 | 214 | export async function run(options: RunnerOptions = {}) { 215 | let runner = new Runner(options); 216 | await runner.run(); 217 | return runner; 218 | } 219 | 220 | export async function runOnce(options: RunnerOptions = {}) { 221 | let runner = new Runner(options); 222 | await runner.runOnce(); 223 | return runner; 224 | } 225 | -------------------------------------------------------------------------------- /src/upsertSchedule.ts: -------------------------------------------------------------------------------- 1 | import parseCron, { CronPattern } from "./parseCron"; 2 | import { RunnerOptions } from "./runner"; 3 | import { processOptions } from "./lib"; 4 | 5 | export interface Schedule { 6 | name: string; 7 | pattern: string | CronPattern; 8 | timeZone: string; 9 | 10 | taskIdentifier?: string; 11 | queueName?: string; 12 | maxAttempts?: number; 13 | } 14 | 15 | export interface Options { 16 | inlineValues?: boolean; 17 | } 18 | 19 | export default function upsertSchedule( 20 | { name, pattern, timeZone, taskIdentifier, queueName, maxAttempts }: Schedule, 21 | runnerOptions: RunnerOptions, 22 | { inlineValues = false }: Options = {} 23 | ): [string, any[]] { 24 | let cron: CronPattern; 25 | if (typeof pattern === "string") { 26 | cron = parseCron(pattern); 27 | } else { 28 | cron = pattern; 29 | } 30 | const { escapedSchedulerSchema } = processOptions(runnerOptions); 31 | const task = taskIdentifier ?? name; 32 | 33 | let args: any[] = []; 34 | let values: string[] = []; 35 | for (const value of [ 36 | cron.minute, 37 | cron.hour, 38 | cron.day, 39 | cron.month, 40 | cron.dow, 41 | ]) { 42 | if (inlineValues) { 43 | values.push(`'{${value.join(",")}}'`); 44 | } else { 45 | args.push(value); 46 | values.push(`$${args.length}`); 47 | } 48 | } 49 | for (const value of [name, timeZone, task, queueName, maxAttempts]) { 50 | if (value == null) { 51 | values.push("DEFAULT"); 52 | } else if (inlineValues) { 53 | if (typeof value === "number") { 54 | values.push(`${value}`); 55 | } else if (typeof value === "object") { 56 | values.push(`'${JSON.stringify(value)}'`); 57 | } else { 58 | values.push(`'${value}'`); 59 | } 60 | } else { 61 | args.push(value); 62 | values.push(`$${args.length}`); 63 | } 64 | } 65 | 66 | let sql = `INSERT INTO ${escapedSchedulerSchema}."schedules" 67 | (minute, hour, day, month, dow, schedule_name, timezone, task_identifier, queue_name, max_attempts) 68 | VALUES (${values.join(", ")}) 69 | ON CONFLICT (schedule_name) DO UPDATE SET 70 | minute = EXCLUDED.minute, 71 | hour = EXCLUDED.hour, 72 | day = EXCLUDED.day, 73 | month = EXCLUDED.month, 74 | dow = EXCLUDED.dow, 75 | timezone = EXCLUDED.timezone, 76 | task_identifier = EXCLUDED.task_identifier, 77 | queue_name = EXCLUDED.queue_name, 78 | max_attempts = EXCLUDED.max_attempts;`; 79 | 80 | return [sql, args]; 81 | } 82 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "declarationDir": "./dist", 5 | "outDir": "./dist", 6 | "declaration": true, 7 | "allowJs": false, 8 | "target": "es2017", 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "sourceMap": true, 12 | "pretty": true, 13 | "importHelpers": true, 14 | "experimentalDecorators": true, 15 | "noImplicitAny": true, 16 | "suppressImplicitAnyIndexErrors": true, 17 | "strictNullChecks": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "noUnusedParameters": true, 20 | "noUnusedLocals": true, 21 | "preserveWatchOutput": true, 22 | "lib": ["es2018", "esnext.asynciterable"] 23 | }, 24 | "include": ["src/**/*"] 25 | } 26 | --------------------------------------------------------------------------------