-
Notifications
You must be signed in to change notification settings - Fork 168
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(server): fixing double summary emails per week (#1054)
* feat(server task scheduler): sketch out core task scheduler implementation * feat(server weekly activity digests): add function lock duration to the weekly digest execution * feat(server scheduled tasks): add scheduled tasks type definition, db schema and migration * feat(server scheduled tasks): add scheduled tasks repository * feat(server task scheduler): add task scheduler service implementation * chore(server deps): add mocha type definitions * refactor(server scheduled tasks): refactor scheduled tasks migration * refactor(server scheduled tasks): refactor scheduled task db schema and type definitions * feat(server scheduled tasks): implement db side lock acquire * refactor(server scheduled tasks): refactor task scheduler with lock on query mechanism * test(server scheduled tasks): add tests for scheduled tasks implementation * refactor(server weekly activity digests): refactor to new task scheduler implementation * feat(server weekly activity digest): switch to a 1000 seconds trigger period for testing purposes * fix(server task scheduler): fix not catching lock acquire function errors Co-authored-by: Gergő Jedlicska <[email protected]>
- Loading branch information
1 parent
97ac4a3
commit 1351b6b
Showing
10 changed files
with
255 additions
and
16 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
14 changes: 14 additions & 0 deletions
14
packages/server/modules/core/migrations/20220929141717_scheduled_tasks.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import { Knex } from 'knex' | ||
|
||
const TABLE_NAME = 'scheduled_tasks' | ||
|
||
export async function up(knex: Knex): Promise<void> { | ||
await knex.schema.createTable(TABLE_NAME, (table) => { | ||
table.string('taskName').primary() | ||
table.timestamp('lockExpiresAt', { precision: 3, useTz: true }).notNullable() | ||
}) | ||
} | ||
|
||
export async function down(knex: Knex): Promise<void> { | ||
await knex.schema.dropTableIfExists(TABLE_NAME) | ||
} |
15 changes: 15 additions & 0 deletions
15
packages/server/modules/core/repositories/scheduledTasks.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import { ScheduledTasks } from '@/modules/core/dbSchema' | ||
import { ScheduledTaskRecord } from '@/modules/core/helpers/types' | ||
|
||
export async function acquireTaskLock( | ||
scheduledTask: ScheduledTaskRecord | ||
): Promise<ScheduledTaskRecord | null> { | ||
const now = new Date() | ||
const [lock] = await ScheduledTasks.knex<ScheduledTaskRecord>() | ||
.insert(scheduledTask) | ||
.onConflict(ScheduledTasks.withoutTablePrefix.col.taskName) | ||
.merge() | ||
.where(ScheduledTasks.col.lockExpiresAt, '<', now) | ||
.returning('*') | ||
return (lock as ScheduledTaskRecord) ?? null | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import cron from 'node-cron' | ||
import { InvalidArgumentError } from '@/modules/shared/errors' | ||
import { modulesDebug, errorDebug } from '@/modules/shared/utils/logger' | ||
import { ensureError } from '@/modules/shared/helpers/errorHelper' | ||
import { acquireTaskLock } from '@/modules/core/repositories/scheduledTasks' | ||
import { ScheduledTaskRecord } from '@/modules/core/helpers/types' | ||
|
||
const activitiesDebug = modulesDebug.extend('activities') | ||
|
||
export const scheduledCallbackWrapper = async ( | ||
scheduledTime: Date, | ||
taskName: string, | ||
lockTimeout: number, | ||
callback: (scheduledTime: Date) => Promise<void>, | ||
acquireLock: ( | ||
scheduledTask: ScheduledTaskRecord | ||
) => Promise<ScheduledTaskRecord | null> | ||
) => { | ||
// try to acquire the task lock with the function name and a new expiration date | ||
const lockExpiresAt = new Date(scheduledTime.getTime() + lockTimeout) | ||
try { | ||
const lock = await acquireLock({ taskName, lockExpiresAt }) | ||
|
||
// if couldn't acquire it, stop execution | ||
if (!lock) { | ||
activitiesDebug( | ||
`Could not acquire task lock for ${taskName}, stopping execution.` | ||
) | ||
return null | ||
} | ||
|
||
// else continue executing the callback... | ||
activitiesDebug(`Executing scheduled function ${taskName} at ${scheduledTime}`) | ||
await callback(scheduledTime) | ||
// update lock as succeeded | ||
const finishDate = new Date() | ||
activitiesDebug( | ||
`Finished scheduled function ${taskName} execution in ${ | ||
(finishDate.getTime() - scheduledTime.getTime()) / 1000 | ||
} seconds` | ||
) | ||
} catch (error) { | ||
errorDebug( | ||
`The triggered task execution ${taskName} failed at ${scheduledTime}, with error ${ | ||
ensureError(error, 'unknown reason').message | ||
}` | ||
) | ||
} | ||
} | ||
|
||
export const scheduleExecution = ( | ||
cronExpression: string, | ||
taskName: string, | ||
callback: (scheduledTime: Date) => Promise<void>, | ||
lockTimeout = 60 * 1000 | ||
): cron.ScheduledTask => { | ||
const expressionValid = cron.validate(cronExpression) | ||
if (!expressionValid) | ||
throw new InvalidArgumentError( | ||
`The given cron expression ${cronExpression} is not valid` | ||
) | ||
return cron.schedule(cronExpression, async (scheduledTime: Date) => { | ||
await scheduledCallbackWrapper( | ||
scheduledTime, | ||
taskName, | ||
lockTimeout, | ||
callback, | ||
acquireTaskLock | ||
) | ||
}) | ||
} |
122 changes: 122 additions & 0 deletions
122
packages/server/modules/core/tests/scheduledTasks.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
import { describe } from 'mocha' | ||
import { ScheduledTasks } from '@/modules/core/dbSchema' | ||
import { truncateTables } from '@/test/hooks' | ||
import { acquireTaskLock } from '@/modules/core/repositories/scheduledTasks' | ||
import { ensureError } from '@/modules/shared/helpers/errorHelper' | ||
import { | ||
scheduledCallbackWrapper, | ||
scheduleExecution | ||
} from '@/modules/core/services/taskScheduler' | ||
import { expect } from 'chai' | ||
import { sleep } from '@/test/helpers' | ||
import cryptoRandomString from 'crypto-random-string' | ||
|
||
describe('Scheduled tasks @core', () => { | ||
describe('Task lock repository', () => { | ||
before(async () => { | ||
await truncateTables([ScheduledTasks.name]) | ||
}) | ||
it('can acquire task lock for a new function name', async () => { | ||
const taskName = cryptoRandomString({ length: 10 }) | ||
const scheduledTask = { taskName, lockExpiresAt: new Date() } | ||
const lock = await acquireTaskLock(scheduledTask) | ||
expect(lock).to.be.deep.equal(scheduledTask) | ||
}) | ||
it('can acquire task lock if previous lock has expired', async () => { | ||
const taskName = cryptoRandomString({ length: 10 }) | ||
const oldTask = { taskName, lockExpiresAt: new Date() } | ||
await acquireTaskLock(oldTask) | ||
|
||
await sleep(100) | ||
const newTask = { taskName, lockExpiresAt: new Date() } | ||
const lock = await acquireTaskLock(newTask) | ||
expect(lock).to.be.deep.equal(newTask) | ||
}) | ||
it('returns an invalid lock (null), if there is another lock in place', async () => { | ||
const taskName = cryptoRandomString({ length: 10 }) | ||
const oldTask = { | ||
taskName, | ||
lockExpiresAt: new Date('2366-12-28 00:30:57.000+00') | ||
} | ||
await acquireTaskLock(oldTask) | ||
const newTask = { taskName, lockExpiresAt: new Date() } | ||
const lock = await acquireTaskLock(newTask) | ||
expect(lock).to.be.null | ||
}) | ||
}) | ||
describe('Task scheduler', () => { | ||
describe('scheduled callback wrapper function', () => { | ||
let callbackExecuted = false | ||
async function fakeCallback() { | ||
callbackExecuted = true | ||
} | ||
beforeEach(() => { | ||
callbackExecuted = false | ||
}) | ||
it("doesn't invoke the callback if it aquires an invalid lock", async () => { | ||
expect(callbackExecuted).to.be.false | ||
const taskName = cryptoRandomString({ length: 10 }) | ||
await scheduledCallbackWrapper( | ||
new Date(), | ||
taskName, | ||
100, | ||
fakeCallback, | ||
// fake lock aquire, always returning an invalid lock | ||
async () => null | ||
) | ||
expect(callbackExecuted).to.be.false | ||
}) | ||
it('invokes the callback if a task lock is acquired', async () => { | ||
expect(callbackExecuted).to.be.false | ||
const taskName = cryptoRandomString({ length: 10 }) | ||
await scheduledCallbackWrapper( | ||
new Date(), | ||
taskName, | ||
100, | ||
fakeCallback, | ||
// fake lock aquire, always returning an invalid lock | ||
async () => ({ taskName, lockExpiresAt: new Date() }) | ||
) | ||
expect(callbackExecuted).to.be.true | ||
}) | ||
it('handles all callback errors gracefully', async () => { | ||
expect(callbackExecuted).to.be.false | ||
const taskName = cryptoRandomString({ length: 10 }) | ||
await scheduledCallbackWrapper( | ||
new Date(), | ||
taskName, | ||
100, | ||
async () => { | ||
callbackExecuted = true | ||
throw 'catch this' | ||
}, | ||
// fake lock aquire, always returning an invalid lock | ||
async () => ({ taskName, lockExpiresAt: new Date() }) | ||
) | ||
expect(callbackExecuted).to.be.true | ||
}) | ||
}) | ||
describe('schedule execution', () => { | ||
it('throws an InvalidArgimentError if the cron expression is not valid', async () => { | ||
const cronExpression = 'this is a borked cron expression' | ||
try { | ||
scheduleExecution(cronExpression, 'tick tick boom', async () => { | ||
return | ||
}) | ||
throw new Error('this should have ') | ||
} catch (err) { | ||
expect(ensureError(err).message).to.equal( | ||
`The given cron expression ${cronExpression} is not valid` | ||
) | ||
} | ||
}) | ||
it('returns a cron scheduled task instance if the config is valid', async () => { | ||
const cronExpression = '*/1000 * * * *' | ||
const task = scheduleExecution(cronExpression, 'tick tick boom', async () => { | ||
return | ||
}) | ||
expect(task).to.not.be.null | ||
}) | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters