Skip to content

Commit

Permalink
Merge pull request #19631 from strapi/feat/scheduling-with-multiple-i…
Browse files Browse the repository at this point in the history
…nstances

feat(content-releases): scheduling in multiple strapi instances
  • Loading branch information
Feranchz committed Mar 13, 2024
2 parents a9d79be + 6a9b4d7 commit 298c6fc
Show file tree
Hide file tree
Showing 4 changed files with 333 additions and 221 deletions.
44 changes: 44 additions & 0 deletions docs/docs/docs/01-core/content-releases/03-scheduling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
title: Content Releases Scheduling
description: Content Releases Scheduling
tags:
- content-releases
- tech design
---

:::caution
Content Releases Scheduling is not yet a stable feature. Therefore, all the elements documented on this page are not currently visible. If you wish to try Releases Scheduling, you can enable it **at your own** risk using the `contentReleasesScheduling` feature flag.
:::

Scheduling provides users with the ability to set a scheduled date for the release, automating its publication or unpublishing. When this happens, a webhook is triggered, providing the result of the attempt to publish the release.

## How it works

Everytime you create or update a release and add a scheduled date, the server responsible for handling this request will generate a new cronjob (utilizing node-schedule) for the selected date to publish the release.

## Timezones

When selecting a scheduled date, you have the option to choose a specific timezone; by default, your system timezone is selected. This measure is taken to prevent any potential confusion when selecting the publication time for a release. Consequently, if a user sets a schedule for 16:00 using the "Europe/Paris" timezone (UTC+01:00), another user accessing the same release will see the same time (16:00 (UTC+01:00)), regardless of their system's timezone.

## Scheduling in a architecture with multiple Strapi instances

It's possible that your Strapi project runs on multiple instances. In such cases, what happens with the cronjobs? Do they all run simultaneously, attempting to publish the release multiple times? To understand how we address this scenario, it's important to differentiate between two cases when scheduling a release:

### Release scheduled on runtime

If you have 3 Strapi instances running concurrently, and you distribute traffic among them using any method, there is not a big problem. This is because the server responsible for handling one request to create/update a release and add a schedule will be the only server with the associated cronjob. Then, there's no duplication, and potential race condition problems are avoided.

### Starting a strapi instance

The problem is starting a new Strapi instance, because we retrieve all scheduled releases and ensure that cronjobs are created for each one. Consequently, multiple Strapi instances might end up with the same cronjob for a release publish. To address this, we implement the following logic:

<img
src="/img/content-manager/content-releases/scheduling-publish.png"
alt="a diagram overview explaining the publish release flow"
/>

We set up a transaction that locks the release being published using SQL forUpdate. This means that any other processes attempting to access the release row will be put on hold until the first one finishes executing.

If the validation of the release entries is successful, the publish action proceeds smoothly. In this scenario, we update the releasedAt column of the release with the current date and release the row lock. Subsequently, any incoming processes attempting to access the release would simply encounter an error because the release has already been published.

On the other hand, if the publish process fails, we update the release's status to "failed". When the status is marked as failed, any subsequent attempts to publish will fail silently. The "failed" status only changes when a user makes alterations to the release.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import createReleaseService from '../release';

const mockSchedulingSet = jest.fn();
const mockSchedulingCancel = jest.fn();
const mockExecute = jest.fn();

const baseStrapiMock = {
utils: {
Expand Down Expand Up @@ -30,13 +31,30 @@ const baseStrapiMock = {
query: jest.fn().mockReturnValue({
update: jest.fn(),
}),
transaction: jest
.fn()
.mockImplementation((fn) =>
fn ? fn({ trx: jest.fn() }) : { commit: jest.fn(), get: jest.fn() }
),
queryBuilder: jest.fn().mockReturnValue({
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
first: jest.fn().mockReturnThis(),
transacting: jest.fn().mockReturnThis(),
forUpdate: jest.fn().mockReturnThis(),
execute: mockExecute,
update: jest.fn().mockReturnThis(),
}),
},
eventHub: {
emit: jest.fn(),
},
telemetry: {
send: jest.fn().mockReturnValue(true),
},
log: {
info: jest.fn(),
},
};

const mockUser = {
Expand Down Expand Up @@ -87,6 +105,7 @@ describe('release service', () => {
update: jest.fn().mockReturnValue(null),
},
};

// @ts-expect-error Ignore missing properties
const releaseService = createReleaseService({ strapi: strapiMock });

Expand Down Expand Up @@ -300,38 +319,33 @@ describe('release service', () => {

describe('publish', () => {
it('throws an error if the release does not exist', () => {
const strapiMock = {
...baseStrapiMock,
entityService: {
findOne: jest.fn().mockReturnValue(null),
},
};
mockExecute.mockReturnValueOnce(null);

// @ts-expect-error Ignore missing properties
const releaseService = createReleaseService({ strapi: strapiMock });
const releaseService = createReleaseService({ strapi: baseStrapiMock });

expect(() => releaseService.publish(1)).rejects.toThrow('No release found for id 1');
});

it('throws an error if the release is already published', () => {
const strapiMock = {
...baseStrapiMock,
entityService: {
findOne: jest.fn().mockReturnValue({ releasedAt: new Date() }),
},
};
mockExecute.mockReturnValueOnce({ id: 1, releasedAt: new Date() });

// @ts-expect-error Ignore missing properties
const releaseService = createReleaseService({ strapi: strapiMock });
const releaseService = createReleaseService({ strapi: baseStrapiMock });

expect(() => releaseService.publish(1)).rejects.toThrow('Release already published');
});

it('throws an error if the release have 0 actions', () => {
mockExecute.mockReturnValueOnce({ id: 1, releasedAt: null });

const strapiMock = {
...baseStrapiMock,
entityService: {
findOne: jest.fn().mockReturnValue({ releasedAt: null, actions: [] }),
db: {
...baseStrapiMock.db,
query: jest.fn().mockReturnValue({
findMany: jest.fn().mockReturnValue([]),
}),
},
};

Expand All @@ -342,6 +356,7 @@ describe('release service', () => {
});

it('calls publishMany for each collectionType with the right actions and publish for singleTypes', async () => {
mockExecute.mockReturnValueOnce({ id: 1, releasedAt: null });
const mockPublishMany = jest.fn();
const mockUnpublishMany = jest.fn();
const mockPublish = jest.fn();
Expand All @@ -364,16 +379,41 @@ describe('release service', () => {

const strapiMock = {
...baseStrapiMock,
db: {
transaction: jest.fn().mockImplementation((fn) => fn({ onRollback: jest.fn() })),
},
plugin: jest.fn().mockReturnValue({
service: jest
.fn()
.mockImplementation((service: 'entity-manager' | 'populate-builder') => {
return servicesMock[service];
}),
}),
db: {
...baseStrapiMock.db,
query: jest.fn().mockReturnValue({
findMany: jest.fn().mockReturnValue([
{
contentType: 'collectionType',
type: 'publish',
entry: { id: 1 },
},
{
contentType: 'collectionType',
type: 'unpublish',
entry: { id: 2 },
},
{
contentType: 'singleType',
type: 'publish',
entry: { id: 3 },
},
{
contentType: 'singleType',
type: 'unpublish',
entry: { id: 4 },
},
]),
update: jest.fn(),
}),
},
entityService: {
findOne: jest.fn(),
findMany: jest.fn(),
Expand All @@ -392,33 +432,6 @@ describe('release service', () => {
// @ts-expect-error Ignore missing properties
const releaseService = createReleaseService({ strapi: strapiMock });

// We mock the first call to findOne to get the release info
strapiMock.entityService.findOne.mockReturnValueOnce({
releasedAt: null,
actions: [
{
contentType: 'collectionType',
type: 'publish',
entry: { id: 1 },
},
{
contentType: 'collectionType',
type: 'unpublish',
entry: { id: 2 },
},
{
contentType: 'singleType',
type: 'publish',
entry: { id: 3 },
},
{
contentType: 'singleType',
type: 'unbpublish',
entry: { id: 4 },
},
],
});

// We mock the calls to findOne to get singleType entries info
strapiMock.entityService.findOne.mockReturnValueOnce({
id: 3,
Expand Down

0 comments on commit 298c6fc

Please sign in to comment.