-
Notifications
You must be signed in to change notification settings - Fork 7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(datatrak): RN-1398: Setup task scheduler #5841
Changes from 5 commits
e19d673
c8bd2b3
95b23f9
cae50cd
c11409c
0e19334
2b68866
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
/* | ||
* Tupaia | ||
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd | ||
*/ | ||
import '@babel/polyfill'; | ||
import { configureEnv } from '../src/configureEnv'; | ||
import { ModelRegistry, TupaiaDatabase } from '@tupaia/database'; | ||
import winston from '../src/log'; | ||
import { TaskOverdueChecker } from '../src/scheduledTasks'; | ||
import * as modelClasses from '../src/database/models'; | ||
|
||
const TASKS = { | ||
TaskOverdueChecker, | ||
}; | ||
|
||
configureEnv(); | ||
|
||
const getTaskArg = argv => { | ||
const taskAgr = argv[4]; | ||
if (!taskAgr || !Object.keys(TASKS).find(t => t === taskAgr)) { | ||
const availableOptions = Object.keys(TASKS).join(', '); | ||
throw new Error(`You need to specify one of the following tasks to run: ${availableOptions}`); | ||
} | ||
|
||
return argv[4]; | ||
}; | ||
|
||
(async () => { | ||
const database = new TupaiaDatabase(); | ||
try { | ||
winston.info('Starting scheduled task script'); | ||
const start = Date.now(); | ||
const taskArg = getTaskArg(process.argv); | ||
const taskKey = Object.keys(TASKS).find(t => t === taskArg); | ||
const taskModule = taskKey && TASKS[taskKey]; | ||
winston.info(`Running ${taskArg} module`); | ||
const models = new ModelRegistry(database, modelClasses, true); | ||
const taskInstance = new taskModule(models); | ||
await taskInstance.run(); | ||
const end = Date.now(); | ||
winston.info(`Completed in ${end - start}ms`); | ||
} catch (error) { | ||
winston.error(error.message); | ||
winston.error(error.stack); | ||
} finally { | ||
await database.closeConnections(); | ||
} | ||
})(); |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -5,6 +5,7 @@ | |||||
|
||||||
import '@babel/polyfill'; | ||||||
import http from 'http'; | ||||||
import nodeSchedule from 'node-schedule'; | ||||||
import { | ||||||
AnalyticsRefresher, | ||||||
EntityHierarchyCacher, | ||||||
|
@@ -24,7 +25,7 @@ import { startSyncWithMs1 } from './ms1'; | |||||
import { startSyncWithKoBo } from './kobo'; | ||||||
import { startFeedScraper } from './social'; | ||||||
import { createApp } from './createApp'; | ||||||
|
||||||
import { TaskOverdueChecker } from './scheduledTasks'; | ||||||
import winston from './log'; | ||||||
import { configureEnv } from './configureEnv'; | ||||||
|
||||||
|
@@ -69,6 +70,11 @@ configureEnv(); | |||||
const taskAssigneeEmailer = new TaskAssigneeEmailer(models); | ||||||
taskAssigneeEmailer.listenForChanges(); | ||||||
|
||||||
/** | ||||||
* Scheduled tasks | ||||||
*/ | ||||||
new TaskOverdueChecker(models).init(); | ||||||
|
||||||
/** | ||||||
* Set up actual app with routes etc. | ||||||
*/ | ||||||
|
@@ -117,4 +123,11 @@ configureEnv(); | |||||
winston.error(error.message); | ||||||
} | ||||||
} | ||||||
|
||||||
/** | ||||||
* Gracefully handle shutdown of ChangeHandlers and ScheduledTasks | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
*/ | ||||||
process.on('SIGINT', function () { | ||||||
nodeSchedule.gracefulShutdown().then(() => process.exit(0)); | ||||||
}); | ||||||
})(); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
/* | ||
* Tupaia | ||
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd | ||
*/ | ||
|
||
import { scheduleJob } from 'node-schedule'; | ||
import winston from 'winston'; | ||
|
||
/** | ||
* Base class for scheduled tasks. Uses 'node-schedule' for scheduling based on cron tab syntax | ||
* Subclasses should implement the run method and need to be initialised by instantiating the | ||
* class and calling init in the central-server index.js file | ||
*/ | ||
export class ScheduledTask { | ||
getSchedule() { | ||
throw new Error(`ScheduledTask::getSchedule not overridden for ${this.constructor.name}`); | ||
} | ||
|
||
getName() { | ||
throw new Error(`ScheduledTask::getName not overridden for ${this.constructor.name}`); | ||
} | ||
|
||
constructor(models, lockKey) { | ||
winston.info(`Initialising scheduled task ${this.getName()}`); | ||
this.models = models; | ||
this.lockKey = lockKey; | ||
this.schedule = this.getSchedule(); | ||
this.name = this.getName(); | ||
this.job = null; | ||
this.start = null; | ||
} | ||
|
||
async run() { | ||
throw new Error('Any subclass of ScheduledTask must implement the "run" method'); | ||
} | ||
|
||
async runTask() { | ||
this.start = Date.now(); | ||
|
||
try { | ||
await this.models.wrapInTransaction(async transactingModels => { | ||
// Acquire a database advisory lock for the transaction | ||
// Ensures no other server instance can execute its change handler at the same time | ||
await transactingModels.database.acquireAdvisoryLockForTransaction(this.lockKey); | ||
await this.run(); | ||
const durationMs = Date.now() - this.start; | ||
winston.info(`ScheduledTask: ${this.name}: Succeeded in ${durationMs}`); | ||
return true; | ||
}); | ||
} catch (e) { | ||
const durationMs = Date.now() - this.start; | ||
winston.error(`ScheduledTask: ${this.name}: Failed`, { id: runId, durationMs }); | ||
winston.error(e.stack); | ||
|
||
return false; | ||
} finally { | ||
this.start = null; | ||
} | ||
} | ||
|
||
init() { | ||
if (!this.job) { | ||
const name = this.getName(); | ||
winston.info(`ScheduledTask: ${name}: Scheduled for ${this.schedule}`); | ||
this.job = scheduleJob(this.schedule, async () => { | ||
await this.runTask(); | ||
}); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
/* | ||
* Tupaia | ||
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd | ||
*/ | ||
import { sendEmail } from '@tupaia/server-utils'; | ||
import { format } from 'date-fns'; | ||
import winston from 'winston'; | ||
import { ScheduledTask } from './ScheduledTask'; | ||
|
||
export class TaskOverdueChecker extends ScheduledTask { | ||
getSchedule() { | ||
return '0 * * * *'; // every hour | ||
} | ||
|
||
getName() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thoughts on making it possible to just set There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes good point. I liked the functions so that I could have the nice errors but I found another easy way of handling that. |
||
return 'TaskOverdueChecker'; | ||
} | ||
|
||
constructor(models) { | ||
super(models, 'task-overdue-checker'); | ||
} | ||
|
||
async run() { | ||
const { task, user } = this.models; | ||
const overdueTasks = await task.find({ | ||
task_status: 'overdue', | ||
}); | ||
|
||
winston.info(`Found ${overdueTasks.length} overdue task(s)`); | ||
|
||
for (const task of overdueTasks) { | ||
const assignee = await user.findById(task.assignee_id); | ||
|
||
const result = await sendEmail(assignee.email, { | ||
subject: 'Task overdue on Tupaia.org', | ||
templateName: 'overdueTask', | ||
templateContext: { | ||
userName: assignee.first_name, | ||
surveyName: task.survey_name, | ||
entityName: task.entity_name, | ||
dueDate: format(new Date(task.due_date), 'do MMMM yyyy'), | ||
}, | ||
}); | ||
winston.info(`Email sent to ${assignee.email} with status: ${result.response}`); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
/* | ||
* Tupaia | ||
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd | ||
*/ | ||
|
||
export { TaskOverdueChecker } from './TaskOverdueChecker'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
<div> | ||
<p>Hi {{userName}},</p> | ||
<p> | ||
Oh no! Looks like you have an overdue task. | ||
</p> | ||
<p> | ||
This is just to let you know that you have an overdue task. The task is {{surveyName}} for {{entityName}} and was due on the {{dueDate}}. To view and complete your tasks head to DataTrak. | ||
</p> | ||
<p> | ||
Have fun using the platform and feel free to get in touch if you have any questions. | ||
</p> | ||
<p> | ||
Cheers | ||
</p> | ||
<p> | ||
The Tupaia Team | ||
</p> | ||
</div> |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is picky but I can just see us getting confused between tasks and schedules tasks. Perhaps we can call these scheduled jobs/events instead so the distinction is clear?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yep good point