chore: replace node-schedule with strapi.cron service

This commit is contained in:
Giulio Montagner 2025-07-28 14:58:05 +02:00 committed by GitHub
parent 7f13d09efd
commit e648c19c20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 119 additions and 118 deletions

View File

@ -1,12 +1,6 @@
import { createAuditLogsLifecycleService } from '../lifecycles';
import { scheduleJob } from 'node-schedule';
import '@strapi/types';
jest.mock('node-schedule', () => ({
scheduleJob: jest.fn(),
}));
describe('Audit logs service', () => {
const mockSubscribe = jest.fn();
@ -37,6 +31,10 @@ describe('Audit logs service', () => {
get: jest.fn(() => ({
deleteExpiredEvents: jest.fn(),
})),
cron: {
add: jest.fn(),
remove: jest.fn(),
},
config: {
get(key: any) {
switch (key) {
@ -103,15 +101,18 @@ describe('Audit logs service', () => {
});
it('should create a cron job that executed one time a day', async () => {
// @ts-expect-error scheduleJob
const mockScheduleJob = scheduleJob.mockImplementationOnce(
jest.fn((rule, callback) => callback())
);
// Mock Strapi EE feature to be enabled for this test
jest.mocked(strapi.ee.features.isEnabled).mockReturnValueOnce(true);
const lifecycle = createAuditLogsLifecycleService(strapi);
await lifecycle.register();
expect(mockScheduleJob).toHaveBeenCalledTimes(1);
expect(mockScheduleJob).toHaveBeenCalledWith('0 0 * * *', expect.any(Function));
// Verify that strapi.cron.add was called with the correct job configuration
expect(strapi.cron.add).toHaveBeenCalledWith({
deleteExpiredAuditLogs: {
task: expect.any(Function),
options: '0 0 * * *',
},
});
});
});

View File

@ -1,5 +1,4 @@
import type { Core } from '@strapi/types';
import { scheduleJob } from 'node-schedule';
const DEFAULT_RETENTION_DAYS = 90;
@ -156,9 +155,15 @@ const createAuditLogsLifecycleService = (strapi: Core.Strapi) => {
// Manage audit logs auto deletion
const retentionDays = getRetentionDays(strapi);
state.deleteExpiredJob = scheduleJob('0 0 * * *', () => {
const expirationDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
auditLogsService.deleteExpiredEvents(expirationDate);
strapi.cron.add({
deleteExpiredAuditLogs: {
task: async () => {
const expirationDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
auditLogsService.deleteExpiredEvents(expirationDate);
},
options: '0 0 * * *',
},
});
return this;
@ -173,9 +178,7 @@ const createAuditLogsLifecycleService = (strapi: Core.Strapi) => {
state.eventHubUnsubscribe();
}
if (state.deleteExpiredJob) {
state.deleteExpiredJob.cancel();
}
strapi.cron.remove('deleteExpiredAuditLogs');
return this;
},

View File

@ -118,7 +118,6 @@
"koa-static": "5.0.0",
"koa2-ratelimit": "^1.1.3",
"lodash": "4.17.21",
"node-schedule": "2.1.1",
"ora": "5.4.1",
"p-map": "4.0.0",
"passport-local": "1.0.0",

View File

@ -88,7 +88,6 @@
"markdown-it-mark": "^3.0.1",
"markdown-it-sub": "^1.0.0",
"markdown-it-sup": "1.0.0",
"node-schedule": "2.1.1",
"prismjs": "1.30.0",
"qs": "6.11.1",
"react-dnd": "16.0.1",

View File

@ -1,12 +1,7 @@
import type { UID } from '@strapi/types';
import { scheduleJob } from 'node-schedule';
import { HISTORY_VERSION_UID } from '../../constants';
import { createLifecyclesService } from '../lifecycles';
jest.mock('node-schedule', () => ({
scheduleJob: jest.fn(),
}));
const mockGetRequestContext = jest.fn(() => {
return {
state: {
@ -73,6 +68,9 @@ const mockStrapi = {
config: {
get: () => undefined,
},
cron: {
add: jest.fn(),
},
};
// @ts-expect-error - ignore
mockStrapi.documents.use = jest.fn();
@ -93,14 +91,15 @@ describe('history lifecycles service', () => {
});
it('should create a cron job that runs once a day', async () => {
// @ts-expect-error - this is a mock
const mockScheduleJob = scheduleJob.mockImplementationOnce(
jest.fn((rule, callback) => callback())
);
await lifecyclesService.bootstrap();
expect(mockScheduleJob).toHaveBeenCalledTimes(1);
expect(mockScheduleJob).toHaveBeenCalledWith('historyDaily', '0 0 * * *', expect.any(Function));
expect(mockStrapi.cron.add).toHaveBeenCalledTimes(1);
expect(mockStrapi.cron.add).toHaveBeenCalledWith(
expect.objectContaining({
deleteHistoryDaily: expect.objectContaining({
task: expect.any(Function),
}),
})
);
});
});

View File

@ -3,8 +3,6 @@ import { contentTypes } from '@strapi/utils';
import { omit, castArray } from 'lodash/fp';
import { scheduleJob } from 'node-schedule';
import { getService } from '../utils';
import { FIELDS_TO_IGNORE, HISTORY_VERSION_UID } from '../constants';
@ -93,10 +91,8 @@ const getSchemas = (uid: UID.CollectionType) => {
const createLifecyclesService = ({ strapi }: { strapi: Core.Strapi }) => {
const state: {
deleteExpiredJob: ReturnType<typeof scheduleJob> | null;
isInitialized: boolean;
} = {
deleteExpiredJob: null,
isInitialized: false,
};
@ -176,33 +172,37 @@ const createLifecyclesService = ({ strapi }: { strapi: Core.Strapi }) => {
});
// Schedule a job to delete expired history versions every day at midnight
state.deleteExpiredJob = scheduleJob('historyDaily', '0 0 * * *', () => {
const retentionDaysInMilliseconds = serviceUtils.getRetentionDays() * 24 * 60 * 60 * 1000;
const expirationDate = new Date(Date.now() - retentionDaysInMilliseconds);
strapi.cron.add({
deleteHistoryDaily: {
async task() {
const retentionDaysInMilliseconds =
serviceUtils.getRetentionDays() * 24 * 60 * 60 * 1000;
const expirationDate = new Date(Date.now() - retentionDaysInMilliseconds);
strapi.db
.query(HISTORY_VERSION_UID)
.deleteMany({
where: {
created_at: {
$lt: expirationDate,
},
},
})
.catch((error) => {
if (error instanceof Error) {
strapi.log.error('Error deleting expired history versions', error.message);
}
});
strapi.db
.query(HISTORY_VERSION_UID)
.deleteMany({
where: {
created_at: {
$lt: expirationDate,
},
},
})
.catch((error) => {
if (error instanceof Error) {
strapi.log.error('Error deleting expired history versions', error.message);
}
});
},
options: '0 0 * * *',
},
});
state.isInitialized = true;
},
async destroy() {
if (state.deleteExpiredJob) {
state.deleteExpiredJob.cancel();
}
strapi.cron.remove('deleteHistoryDaily');
},
};
};

View File

@ -68,7 +68,6 @@
"date-fns-tz": "2.0.1",
"formik": "2.4.5",
"lodash": "4.17.21",
"node-schedule": "2.1.1",
"qs": "6.11.1",
"react-intl": "6.6.2",
"react-redux": "8.1.3",

View File

@ -1,15 +1,14 @@
import { Job } from 'node-schedule';
import type { Core } from '@strapi/types';
import { Release } from '../../shared/contracts/releases';
import { getService } from './utils';
export const destroy = async ({ strapi }: { strapi: Core.Strapi }) => {
const scheduledJobs: Map<Release['id'], Job> = getService('scheduling', {
const scheduledJobs: Map<Release['id'], string> = getService('scheduling', {
strapi,
}).getAll();
for (const [, job] of scheduledJobs) {
job.cancel();
for (const [, taskName] of scheduledJobs) {
strapi.cron.remove(taskName);
}
};

View File

@ -1,4 +1,3 @@
import { scheduleJob } from 'node-schedule';
import createSchedulingService from '../scheduling';
const baseStrapiMock = {
@ -7,13 +6,17 @@ const baseStrapiMock = {
isEnabled: jest.fn().mockReturnValue(true),
},
},
cron: {
add: jest.fn(),
remove: jest.fn(),
},
};
jest.mock('node-schedule', () => ({
scheduleJob: jest.fn(),
}));
describe('Scheduling service', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('set', () => {
it('should throw an error if the release does not exist', async () => {
const strapiMock = {
@ -33,10 +36,6 @@ describe('Scheduling service', () => {
});
it('should cancel the previous job if it exists and create the new one', async () => {
const mockScheduleJob = jest.fn().mockReturnValue({ cancel: jest.fn() });
// @ts-expect-error - scheduleJob is a mock
scheduleJob.mockImplementation(mockScheduleJob);
const strapiMock = {
...baseStrapiMock,
db: {
@ -53,21 +52,17 @@ describe('Scheduling service', () => {
const schedulingService = createSchedulingService({ strapi: strapiMock });
const scheduledJobs = await schedulingService.set('1', oldJobDate);
expect(scheduledJobs.size).toBe(1);
expect(mockScheduleJob).toHaveBeenCalledWith(oldJobDate, expect.any(Function));
expect(strapiMock.cron.add).toHaveBeenCalledTimes(1);
const oldJob = scheduledJobs.get('1')!;
const oldTaskName = scheduledJobs.get('1')!;
await schedulingService.set('1', newJobDate);
expect(oldJob.cancel).toHaveBeenCalled();
expect(mockScheduleJob).toHaveBeenCalledWith(newJobDate, expect.any(Function));
expect(strapiMock.cron.remove).toHaveBeenCalledWith(oldTaskName);
expect(strapiMock.cron.add).toHaveBeenCalledTimes(2);
});
it('should create a new job', async () => {
const mockScheduleJob = jest.fn().mockReturnValue({ cancel: jest.fn() });
// @ts-expect-error - scheduleJob is a mock
scheduleJob.mockImplementation(mockScheduleJob);
const strapiMock = {
...baseStrapiMock,
db: {
@ -83,16 +78,12 @@ describe('Scheduling service', () => {
const schedulingService = createSchedulingService({ strapi: strapiMock });
const scheduledJobs = await schedulingService.set('1', date);
expect(scheduledJobs.size).toBe(1);
expect(mockScheduleJob).toHaveBeenCalledWith(date, expect.any(Function));
expect(strapiMock.cron.add).toHaveBeenCalledTimes(1);
});
});
describe('cancel', () => {
it('should cancel the job if it exists', async () => {
const mockScheduleJob = jest.fn().mockReturnValue({ cancel: jest.fn() });
// @ts-expect-error - scheduleJob is a mock
scheduleJob.mockImplementation(mockScheduleJob);
const strapiMock = {
...baseStrapiMock,
db: {
@ -108,19 +99,18 @@ describe('Scheduling service', () => {
const schedulingService = createSchedulingService({ strapi: strapiMock });
const scheduledJobs = await schedulingService.set('1', date);
expect(scheduledJobs.size).toBe(1);
expect(mockScheduleJob).toHaveBeenCalledWith(date, expect.any(Function));
expect(strapiMock.cron.add).toHaveBeenCalledTimes(1);
const taskName = scheduledJobs.get('1')!;
schedulingService.cancel('1');
expect(strapiMock.cron.remove).toHaveBeenCalledWith(taskName);
expect(scheduledJobs.size).toBe(0);
});
});
describe('getAll', () => {
it('should return all the scheduled jobs', async () => {
const mockScheduleJob = jest.fn().mockReturnValue({ cancel: jest.fn() });
// @ts-expect-error - scheduleJob is a mock
scheduleJob.mockImplementation(mockScheduleJob);
const strapiMock = {
...baseStrapiMock,
db: {
@ -141,10 +131,6 @@ describe('Scheduling service', () => {
describe('syncFromDatabase', () => {
it('should sync the scheduled jobs from the database', async () => {
const mockScheduleJob = jest.fn().mockReturnValue({ cancel: jest.fn() });
// @ts-expect-error - scheduleJob is a mock
scheduleJob.mockImplementation(mockScheduleJob);
const strapiMock = {
...baseStrapiMock,
db: {
@ -161,7 +147,7 @@ describe('Scheduling service', () => {
const schedulingService = createSchedulingService({ strapi: strapiMock });
const scheduledJobs = await schedulingService.syncFromDatabase();
expect(scheduledJobs.size).toBe(1);
expect(mockScheduleJob).toHaveBeenCalledWith(expect.any(Date), expect.any(Function));
expect(strapiMock.cron.add).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -1,4 +1,3 @@
import { scheduleJob, Job } from 'node-schedule';
import type { Core } from '@strapi/types';
import { errors } from '@strapi/utils';
@ -7,7 +6,7 @@ import { getService } from '../utils';
import { RELEASE_MODEL_UID } from '../constants';
const createSchedulingService = ({ strapi }: { strapi: Core.Strapi }) => {
const scheduledJobs = new Map<Release['id'], Job>();
const scheduledJobs = new Map<Release['id'], string>();
return {
async set(releaseId: Release['id'], scheduleDate: Date) {
@ -19,29 +18,34 @@ const createSchedulingService = ({ strapi }: { strapi: Core.Strapi }) => {
throw new errors.NotFoundError(`No release found for id ${releaseId}`);
}
const job = scheduleJob(scheduleDate, async () => {
try {
await getService('release', { strapi }).publish(releaseId);
// @TODO: Trigger webhook with success message
} catch (error) {
// @TODO: Trigger webhook with error message
}
const taskName = `publishRelease_${releaseId}`;
this.cancel(releaseId);
strapi.cron.add({
[taskName]: {
async task() {
try {
await getService('release', { strapi }).publish(releaseId);
// @TODO: Trigger webhook with success message
} catch (error) {
// @TODO: Trigger webhook with error message
}
},
options: scheduleDate,
},
});
if (scheduledJobs.has(releaseId)) {
this.cancel(releaseId);
}
scheduledJobs.set(releaseId, job);
scheduledJobs.set(releaseId, taskName);
return scheduledJobs;
},
cancel(releaseId: Release['id']) {
if (scheduledJobs.has(releaseId)) {
scheduledJobs.get(releaseId)!.cancel();
strapi.cron.remove(scheduledJobs.get(releaseId)!);
scheduledJobs.delete(releaseId);
}

View File

@ -9,6 +9,7 @@ describe('metrics', () => {
});
test('Initializes a middleware', () => {
const use = jest.fn();
const add = jest.fn();
const metricsInstance = metrics({
config: {
@ -32,6 +33,9 @@ describe('metrics', () => {
requestContext: {
get: jest.fn(() => ({})),
},
cron: {
add,
},
fetch,
} as any);
@ -44,6 +48,7 @@ describe('metrics', () => {
test('Does not init middleware if disabled', () => {
const use = jest.fn();
const add = jest.fn();
const metricsInstance = metrics({
config: {
@ -67,6 +72,9 @@ describe('metrics', () => {
requestContext: {
get: jest.fn(() => ({})),
},
cron: {
add,
},
fetch,
} as any);
@ -100,6 +108,9 @@ describe('metrics', () => {
requestContext: {
get: jest.fn(() => ({})),
},
cron: {
add: jest.fn(),
},
fetch,
} as any);
@ -147,6 +158,9 @@ describe('metrics', () => {
root: process.cwd(),
},
},
cron: {
add: jest.fn(),
},
requestContext: {
get: jest.fn(() => ({})),
},

View File

@ -3,7 +3,6 @@
* You can learn more at https://docs.strapi.io/developer-docs/latest/getting-started/usage-information.html
*/
import { Job, scheduleJob } from 'node-schedule';
import type { Core } from '@strapi/types';
import wrapWithRateLimit from './rate-limiter';
@ -25,7 +24,6 @@ const createTelemetryInstance = (strapi: Core.Strapi) => {
const isDisabled =
!uuid || isTruthy(process.env.STRAPI_TELEMETRY_DISABLED) || isTruthy(telemetryDisabled);
const crons: Job[] = [];
const sender = createSender(strapi);
const sendEvent = wrapWithRateLimit(sender, { limitedEvents: LIMITED_EVENTS });
@ -36,8 +34,12 @@ const createTelemetryInstance = (strapi: Core.Strapi) => {
register() {
if (!isDisabled) {
const pingCron = scheduleJob('0 0 12 * * *', () => sendEvent('ping'));
crons.push(pingCron);
strapi.cron.add({
sendPingEvent: {
task: () => sendEvent('ping'),
options: '0 0 12 * * *',
},
});
strapi.server.use(createMiddleware({ sendEvent }));
}
@ -45,15 +47,14 @@ const createTelemetryInstance = (strapi: Core.Strapi) => {
bootstrap() {},
destroy() {
// Clear open handles
crons.forEach((cron) => cron.cancel());
},
async send(event: string, payload: Record<string, unknown> = {}) {
if (isDisabled) return true;
return sendEvent(event, payload);
},
destroy() {
// Clean up resources if needed
},
};
};

View File

@ -8692,7 +8692,6 @@ __metadata:
koa2-ratelimit: "npm:^1.1.3"
lodash: "npm:4.17.21"
msw: "npm:1.3.0"
node-schedule: "npm:2.1.1"
ora: "npm:5.4.1"
p-map: "npm:4.0.0"
passport-local: "npm:1.0.0"
@ -8804,7 +8803,6 @@ __metadata:
markdown-it-sub: "npm:^1.0.0"
markdown-it-sup: "npm:1.0.0"
msw: "npm:1.3.0"
node-schedule: "npm:2.1.1"
prismjs: "npm:1.30.0"
qs: "npm:6.11.1"
react: "npm:18.3.1"
@ -8855,7 +8853,6 @@ __metadata:
koa: "npm:2.16.1"
lodash: "npm:4.17.21"
msw: "npm:1.3.0"
node-schedule: "npm:2.1.1"
qs: "npm:6.11.1"
react: "npm:18.3.1"
react-dom: "npm:18.3.1"