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 { createAuditLogsLifecycleService } from '../lifecycles';
import { scheduleJob } from 'node-schedule';
import '@strapi/types'; import '@strapi/types';
jest.mock('node-schedule', () => ({
scheduleJob: jest.fn(),
}));
describe('Audit logs service', () => { describe('Audit logs service', () => {
const mockSubscribe = jest.fn(); const mockSubscribe = jest.fn();
@ -37,6 +31,10 @@ describe('Audit logs service', () => {
get: jest.fn(() => ({ get: jest.fn(() => ({
deleteExpiredEvents: jest.fn(), deleteExpiredEvents: jest.fn(),
})), })),
cron: {
add: jest.fn(),
remove: jest.fn(),
},
config: { config: {
get(key: any) { get(key: any) {
switch (key) { switch (key) {
@ -103,15 +101,18 @@ describe('Audit logs service', () => {
}); });
it('should create a cron job that executed one time a day', async () => { it('should create a cron job that executed one time a day', async () => {
// @ts-expect-error scheduleJob // Mock Strapi EE feature to be enabled for this test
const mockScheduleJob = scheduleJob.mockImplementationOnce( jest.mocked(strapi.ee.features.isEnabled).mockReturnValueOnce(true);
jest.fn((rule, callback) => callback())
);
const lifecycle = createAuditLogsLifecycleService(strapi); const lifecycle = createAuditLogsLifecycleService(strapi);
await lifecycle.register(); await lifecycle.register();
expect(mockScheduleJob).toHaveBeenCalledTimes(1); // Verify that strapi.cron.add was called with the correct job configuration
expect(mockScheduleJob).toHaveBeenCalledWith('0 0 * * *', expect.any(Function)); 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 type { Core } from '@strapi/types';
import { scheduleJob } from 'node-schedule';
const DEFAULT_RETENTION_DAYS = 90; const DEFAULT_RETENTION_DAYS = 90;
@ -156,9 +155,15 @@ const createAuditLogsLifecycleService = (strapi: Core.Strapi) => {
// Manage audit logs auto deletion // Manage audit logs auto deletion
const retentionDays = getRetentionDays(strapi); const retentionDays = getRetentionDays(strapi);
state.deleteExpiredJob = scheduleJob('0 0 * * *', () => {
const expirationDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000); strapi.cron.add({
auditLogsService.deleteExpiredEvents(expirationDate); deleteExpiredAuditLogs: {
task: async () => {
const expirationDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
auditLogsService.deleteExpiredEvents(expirationDate);
},
options: '0 0 * * *',
},
}); });
return this; return this;
@ -173,9 +178,7 @@ const createAuditLogsLifecycleService = (strapi: Core.Strapi) => {
state.eventHubUnsubscribe(); state.eventHubUnsubscribe();
} }
if (state.deleteExpiredJob) { strapi.cron.remove('deleteExpiredAuditLogs');
state.deleteExpiredJob.cancel();
}
return this; return this;
}, },

View File

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

View File

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

View File

@ -1,12 +1,7 @@
import type { UID } from '@strapi/types'; import type { UID } from '@strapi/types';
import { scheduleJob } from 'node-schedule';
import { HISTORY_VERSION_UID } from '../../constants'; import { HISTORY_VERSION_UID } from '../../constants';
import { createLifecyclesService } from '../lifecycles'; import { createLifecyclesService } from '../lifecycles';
jest.mock('node-schedule', () => ({
scheduleJob: jest.fn(),
}));
const mockGetRequestContext = jest.fn(() => { const mockGetRequestContext = jest.fn(() => {
return { return {
state: { state: {
@ -73,6 +68,9 @@ const mockStrapi = {
config: { config: {
get: () => undefined, get: () => undefined,
}, },
cron: {
add: jest.fn(),
},
}; };
// @ts-expect-error - ignore // @ts-expect-error - ignore
mockStrapi.documents.use = jest.fn(); 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 () => { 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(); await lifecyclesService.bootstrap();
expect(mockScheduleJob).toHaveBeenCalledTimes(1); expect(mockStrapi.cron.add).toHaveBeenCalledTimes(1);
expect(mockScheduleJob).toHaveBeenCalledWith('historyDaily', '0 0 * * *', expect.any(Function)); 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 { omit, castArray } from 'lodash/fp';
import { scheduleJob } from 'node-schedule';
import { getService } from '../utils'; import { getService } from '../utils';
import { FIELDS_TO_IGNORE, HISTORY_VERSION_UID } from '../constants'; 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 createLifecyclesService = ({ strapi }: { strapi: Core.Strapi }) => {
const state: { const state: {
deleteExpiredJob: ReturnType<typeof scheduleJob> | null;
isInitialized: boolean; isInitialized: boolean;
} = { } = {
deleteExpiredJob: null,
isInitialized: false, isInitialized: false,
}; };
@ -176,33 +172,37 @@ const createLifecyclesService = ({ strapi }: { strapi: Core.Strapi }) => {
}); });
// Schedule a job to delete expired history versions every day at midnight // Schedule a job to delete expired history versions every day at midnight
state.deleteExpiredJob = scheduleJob('historyDaily', '0 0 * * *', () => { strapi.cron.add({
const retentionDaysInMilliseconds = serviceUtils.getRetentionDays() * 24 * 60 * 60 * 1000; deleteHistoryDaily: {
const expirationDate = new Date(Date.now() - retentionDaysInMilliseconds); async task() {
const retentionDaysInMilliseconds =
serviceUtils.getRetentionDays() * 24 * 60 * 60 * 1000;
const expirationDate = new Date(Date.now() - retentionDaysInMilliseconds);
strapi.db strapi.db
.query(HISTORY_VERSION_UID) .query(HISTORY_VERSION_UID)
.deleteMany({ .deleteMany({
where: { where: {
created_at: { created_at: {
$lt: expirationDate, $lt: expirationDate,
}, },
}, },
}) })
.catch((error) => { .catch((error) => {
if (error instanceof Error) { if (error instanceof Error) {
strapi.log.error('Error deleting expired history versions', error.message); strapi.log.error('Error deleting expired history versions', error.message);
} }
}); });
},
options: '0 0 * * *',
},
}); });
state.isInitialized = true; state.isInitialized = true;
}, },
async destroy() { async destroy() {
if (state.deleteExpiredJob) { strapi.cron.remove('deleteHistoryDaily');
state.deleteExpiredJob.cancel();
}
}, },
}; };
}; };

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import { scheduleJob } from 'node-schedule';
import createSchedulingService from '../scheduling'; import createSchedulingService from '../scheduling';
const baseStrapiMock = { const baseStrapiMock = {
@ -7,13 +6,17 @@ const baseStrapiMock = {
isEnabled: jest.fn().mockReturnValue(true), isEnabled: jest.fn().mockReturnValue(true),
}, },
}, },
cron: {
add: jest.fn(),
remove: jest.fn(),
},
}; };
jest.mock('node-schedule', () => ({
scheduleJob: jest.fn(),
}));
describe('Scheduling service', () => { describe('Scheduling service', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('set', () => { describe('set', () => {
it('should throw an error if the release does not exist', async () => { it('should throw an error if the release does not exist', async () => {
const strapiMock = { const strapiMock = {
@ -33,10 +36,6 @@ describe('Scheduling service', () => {
}); });
it('should cancel the previous job if it exists and create the new one', async () => { 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 = { const strapiMock = {
...baseStrapiMock, ...baseStrapiMock,
db: { db: {
@ -53,21 +52,17 @@ describe('Scheduling service', () => {
const schedulingService = createSchedulingService({ strapi: strapiMock }); const schedulingService = createSchedulingService({ strapi: strapiMock });
const scheduledJobs = await schedulingService.set('1', oldJobDate); const scheduledJobs = await schedulingService.set('1', oldJobDate);
expect(scheduledJobs.size).toBe(1); 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); await schedulingService.set('1', newJobDate);
expect(oldJob.cancel).toHaveBeenCalled(); expect(strapiMock.cron.remove).toHaveBeenCalledWith(oldTaskName);
expect(mockScheduleJob).toHaveBeenCalledWith(newJobDate, expect.any(Function)); expect(strapiMock.cron.add).toHaveBeenCalledTimes(2);
}); });
it('should create a new job', async () => { 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 = { const strapiMock = {
...baseStrapiMock, ...baseStrapiMock,
db: { db: {
@ -83,16 +78,12 @@ describe('Scheduling service', () => {
const schedulingService = createSchedulingService({ strapi: strapiMock }); const schedulingService = createSchedulingService({ strapi: strapiMock });
const scheduledJobs = await schedulingService.set('1', date); const scheduledJobs = await schedulingService.set('1', date);
expect(scheduledJobs.size).toBe(1); expect(scheduledJobs.size).toBe(1);
expect(mockScheduleJob).toHaveBeenCalledWith(date, expect.any(Function)); expect(strapiMock.cron.add).toHaveBeenCalledTimes(1);
}); });
}); });
describe('cancel', () => { describe('cancel', () => {
it('should cancel the job if it exists', async () => { 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 = { const strapiMock = {
...baseStrapiMock, ...baseStrapiMock,
db: { db: {
@ -108,19 +99,18 @@ describe('Scheduling service', () => {
const schedulingService = createSchedulingService({ strapi: strapiMock }); const schedulingService = createSchedulingService({ strapi: strapiMock });
const scheduledJobs = await schedulingService.set('1', date); const scheduledJobs = await schedulingService.set('1', date);
expect(scheduledJobs.size).toBe(1); 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'); schedulingService.cancel('1');
expect(strapiMock.cron.remove).toHaveBeenCalledWith(taskName);
expect(scheduledJobs.size).toBe(0); expect(scheduledJobs.size).toBe(0);
}); });
}); });
describe('getAll', () => { describe('getAll', () => {
it('should return all the scheduled jobs', async () => { 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 = { const strapiMock = {
...baseStrapiMock, ...baseStrapiMock,
db: { db: {
@ -141,10 +131,6 @@ describe('Scheduling service', () => {
describe('syncFromDatabase', () => { describe('syncFromDatabase', () => {
it('should sync the scheduled jobs from the database', async () => { 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 = { const strapiMock = {
...baseStrapiMock, ...baseStrapiMock,
db: { db: {
@ -161,7 +147,7 @@ describe('Scheduling service', () => {
const schedulingService = createSchedulingService({ strapi: strapiMock }); const schedulingService = createSchedulingService({ strapi: strapiMock });
const scheduledJobs = await schedulingService.syncFromDatabase(); const scheduledJobs = await schedulingService.syncFromDatabase();
expect(scheduledJobs.size).toBe(1); 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 type { Core } from '@strapi/types';
import { errors } from '@strapi/utils'; import { errors } from '@strapi/utils';
@ -7,7 +6,7 @@ import { getService } from '../utils';
import { RELEASE_MODEL_UID } from '../constants'; import { RELEASE_MODEL_UID } from '../constants';
const createSchedulingService = ({ strapi }: { strapi: Core.Strapi }) => { const createSchedulingService = ({ strapi }: { strapi: Core.Strapi }) => {
const scheduledJobs = new Map<Release['id'], Job>(); const scheduledJobs = new Map<Release['id'], string>();
return { return {
async set(releaseId: Release['id'], scheduleDate: Date) { 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}`); throw new errors.NotFoundError(`No release found for id ${releaseId}`);
} }
const job = scheduleJob(scheduleDate, async () => { const taskName = `publishRelease_${releaseId}`;
try {
await getService('release', { strapi }).publish(releaseId);
// @TODO: Trigger webhook with success message
} catch (error) {
// @TODO: Trigger webhook with error message
}
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)) { if (scheduledJobs.has(releaseId)) {
this.cancel(releaseId); this.cancel(releaseId);
} }
scheduledJobs.set(releaseId, job); scheduledJobs.set(releaseId, taskName);
return scheduledJobs; return scheduledJobs;
}, },
cancel(releaseId: Release['id']) { cancel(releaseId: Release['id']) {
if (scheduledJobs.has(releaseId)) { if (scheduledJobs.has(releaseId)) {
scheduledJobs.get(releaseId)!.cancel(); strapi.cron.remove(scheduledJobs.get(releaseId)!);
scheduledJobs.delete(releaseId); scheduledJobs.delete(releaseId);
} }

View File

@ -9,6 +9,7 @@ describe('metrics', () => {
}); });
test('Initializes a middleware', () => { test('Initializes a middleware', () => {
const use = jest.fn(); const use = jest.fn();
const add = jest.fn();
const metricsInstance = metrics({ const metricsInstance = metrics({
config: { config: {
@ -32,6 +33,9 @@ describe('metrics', () => {
requestContext: { requestContext: {
get: jest.fn(() => ({})), get: jest.fn(() => ({})),
}, },
cron: {
add,
},
fetch, fetch,
} as any); } as any);
@ -44,6 +48,7 @@ describe('metrics', () => {
test('Does not init middleware if disabled', () => { test('Does not init middleware if disabled', () => {
const use = jest.fn(); const use = jest.fn();
const add = jest.fn();
const metricsInstance = metrics({ const metricsInstance = metrics({
config: { config: {
@ -67,6 +72,9 @@ describe('metrics', () => {
requestContext: { requestContext: {
get: jest.fn(() => ({})), get: jest.fn(() => ({})),
}, },
cron: {
add,
},
fetch, fetch,
} as any); } as any);
@ -100,6 +108,9 @@ describe('metrics', () => {
requestContext: { requestContext: {
get: jest.fn(() => ({})), get: jest.fn(() => ({})),
}, },
cron: {
add: jest.fn(),
},
fetch, fetch,
} as any); } as any);
@ -147,6 +158,9 @@ describe('metrics', () => {
root: process.cwd(), root: process.cwd(),
}, },
}, },
cron: {
add: jest.fn(),
},
requestContext: { requestContext: {
get: jest.fn(() => ({})), 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 * 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 type { Core } from '@strapi/types';
import wrapWithRateLimit from './rate-limiter'; import wrapWithRateLimit from './rate-limiter';
@ -25,7 +24,6 @@ const createTelemetryInstance = (strapi: Core.Strapi) => {
const isDisabled = const isDisabled =
!uuid || isTruthy(process.env.STRAPI_TELEMETRY_DISABLED) || isTruthy(telemetryDisabled); !uuid || isTruthy(process.env.STRAPI_TELEMETRY_DISABLED) || isTruthy(telemetryDisabled);
const crons: Job[] = [];
const sender = createSender(strapi); const sender = createSender(strapi);
const sendEvent = wrapWithRateLimit(sender, { limitedEvents: LIMITED_EVENTS }); const sendEvent = wrapWithRateLimit(sender, { limitedEvents: LIMITED_EVENTS });
@ -36,8 +34,12 @@ const createTelemetryInstance = (strapi: Core.Strapi) => {
register() { register() {
if (!isDisabled) { if (!isDisabled) {
const pingCron = scheduleJob('0 0 12 * * *', () => sendEvent('ping')); strapi.cron.add({
crons.push(pingCron); sendPingEvent: {
task: () => sendEvent('ping'),
options: '0 0 12 * * *',
},
});
strapi.server.use(createMiddleware({ sendEvent })); strapi.server.use(createMiddleware({ sendEvent }));
} }
@ -45,15 +47,14 @@ const createTelemetryInstance = (strapi: Core.Strapi) => {
bootstrap() {}, bootstrap() {},
destroy() {
// Clear open handles
crons.forEach((cron) => cron.cancel());
},
async send(event: string, payload: Record<string, unknown> = {}) { async send(event: string, payload: Record<string, unknown> = {}) {
if (isDisabled) return true; if (isDisabled) return true;
return sendEvent(event, payload); return sendEvent(event, payload);
}, },
destroy() {
// Clean up resources if needed
},
}; };
}; };

View File

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