Skip to content

Commit 3e443da

Browse files
authored
fix(api-headless-cms-bulk-actions): empty trash bin processing entries in series (#4351)
1 parent 70318a3 commit 3e443da

File tree

8 files changed

+121
-119
lines changed

8 files changed

+121
-119
lines changed

packages/api-headless-cms-bulk-actions/src/handlers/eventBridgeEventHandler.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,9 @@ export const createEventBridgeHandler = () => {
2323

2424
/**
2525
* Since the event is at the infrastructure level, it has no knowledge about tenancy.
26-
* We loop through all tenants in the system and trigger the "EmptyTrashBins" task.
26+
* We trigger the `hcmsEntriesEmptyTrashBins` using root tenant.
2727
*/
28-
const tenants = await context.tenancy.listTenants();
29-
await context.tenancy.withEachTenant(tenants, async () => {
28+
await context.tenancy.withRootTenant(async () => {
3029
await context.tasks.trigger({
3130
definition: "hcmsEntriesEmptyTrashBins"
3231
});

packages/api-headless-cms-bulk-actions/src/plugins/createBulkActionGraphQL.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ export interface CreateBulkActionGraphQL {
1010

1111
export const createBulkActionGraphQL = (config: CreateBulkActionGraphQL) => {
1212
return new ContextPlugin<HcmsBulkActionsContext>(async context => {
13-
if (!(await isHeadlessCmsReady(context))) {
13+
const tenant = context.tenancy.getCurrentTenant();
14+
const locale = context.i18n.getContentLocale();
15+
16+
if (!locale || !(await isHeadlessCmsReady(context))) {
1417
return;
1518
}
1619

@@ -53,7 +56,10 @@ export const createBulkActionGraphQL = (config: CreateBulkActionGraphQL) => {
5356
});
5457
}
5558
}
56-
}
59+
},
60+
isApplicable: context =>
61+
context.tenancy.getCurrentTenant().id === tenant.id &&
62+
context.i18n.getContentLocale()?.code === locale.code
5763
});
5864

5965
plugin.name = `headless-cms.graphql.schema.bulkAction.${model.modelId}.${config.name}`;

packages/api-headless-cms-bulk-actions/src/plugins/createDefaultGraphQL.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { CmsGraphQLSchemaPlugin, isHeadlessCmsReady } from "@webiny/api-headless
44

55
export const createDefaultGraphQL = () => {
66
return new ContextPlugin<HcmsBulkActionsContext>(async context => {
7-
if (!(await isHeadlessCmsReady(context))) {
7+
const tenant = context.tenancy.getCurrentTenant();
8+
const locale = context.i18n.getContentLocale();
9+
10+
if (!locale || !(await isHeadlessCmsReady(context))) {
811
return;
912
}
1013

@@ -44,7 +47,10 @@ export const createDefaultGraphQL = () => {
4447
data: JSON
4548
): BulkActionResponse
4649
}
47-
`
50+
`,
51+
isApplicable: context =>
52+
context.tenancy.getCurrentTenant().id === tenant.id &&
53+
context.i18n.getContentLocale()?.code === locale.code
4854
});
4955

5056
plugin.name = `headless-cms.graphql.schema.bulkAction.default.${model.modelId}`;
Lines changed: 74 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { createPrivateTaskDefinition, TaskDataStatus } from "@webiny/tasks";
1+
import { createTaskDefinition } from "@webiny/tasks";
2+
import { createDeleteEntry, createListDeletedEntries } from "~/useCases";
23
import {
34
HcmsBulkActionsContext,
4-
IBulkActionOperationByModelInput,
5-
TrashBinCleanUpParams
5+
IEmptyTrashBinsInput,
6+
IEmptyTrashBinsOutput,
7+
IEmptyTrashBinsTaskParams
68
} from "~/types";
7-
import { ChildTasksCleanup } from "~/useCases/internals";
89

910
const calculateDateTimeString = () => {
1011
// Retrieve the retention period from the environment variable WEBINY_TRASH_BIN_RETENTION_PERIOD_DAYS,
@@ -23,122 +24,95 @@ const calculateDateTimeString = () => {
2324
return currentDate.toISOString();
2425
};
2526

26-
const cleanup = async ({ context, task }: TrashBinCleanUpParams) => {
27-
// We want to clean all child tasks and logs, which have no errors.
28-
const childTasksCleanup = new ChildTasksCleanup();
29-
try {
30-
await childTasksCleanup.execute({
31-
context,
32-
task
33-
});
34-
} catch (ex) {
35-
console.error(`Error while cleaning "EmptyTrashBins" child tasks.`, ex);
36-
}
37-
};
38-
3927
export const createEmptyTrashBinsTask = () => {
40-
return createPrivateTaskDefinition<HcmsBulkActionsContext>({
28+
return createTaskDefinition<
29+
HcmsBulkActionsContext,
30+
IEmptyTrashBinsInput,
31+
IEmptyTrashBinsOutput
32+
>({
33+
isPrivate: true,
4134
id: "hcmsEntriesEmptyTrashBins",
4235
title: "Headless CMS - Empty all trash bins",
43-
description:
44-
"Delete all entries found in the trash bin, for each model found in the system.",
45-
maxIterations: 24,
36+
description: "Delete all entries in the trash bin for each model in the system.",
37+
maxIterations: 120,
4638
disableDatabaseLogs: true,
47-
run: async params => {
48-
const { response, isAborted, isCloseToTimeout, context, trigger, input, store } =
49-
params;
39+
run: async (params: IEmptyTrashBinsTaskParams) => {
40+
const { response, isAborted, context, input, isCloseToTimeout } = params;
41+
42+
// Abort the task if needed.
5043
if (isAborted()) {
5144
return response.aborted();
52-
} else if (isCloseToTimeout()) {
53-
return response.continue(
54-
{
55-
...input
56-
},
57-
{
58-
seconds: 30
59-
}
60-
);
6145
}
6246

63-
if (input.triggered) {
64-
const { items } = await context.tasks.listTasks({
65-
where: {
66-
parentId: store.getTask().id,
67-
taskStatus_in: [TaskDataStatus.RUNNING, TaskDataStatus.PENDING]
68-
},
69-
limit: 100000
70-
});
47+
// Fetch all tenants, excluding those already processed.
48+
const baseTenants = await context.tenancy.listTenants();
49+
const executedTenantIds = input.executedTenantIds || [];
50+
const tenants = baseTenants.filter(tenant => !executedTenantIds.includes(tenant.id));
51+
let shouldContinue = false; // Flag to check if task should continue.
7152

72-
if (items.length === 0) {
73-
return response.done(
74-
"Task done: emptying the trash bin for all registered models."
75-
);
53+
// Iterate over each tenant.
54+
await context.tenancy.withEachTenant(tenants, async tenant => {
55+
if (isCloseToTimeout()) {
56+
shouldContinue = true;
57+
return;
7658
}
7759

78-
for (const item of items) {
79-
const status = await context.tasks.fetchServiceInfo(item.id);
80-
81-
if (status?.status === "FAILED" || status?.status === "TIMED_OUT") {
82-
await context.tasks.updateTask(item.id, {
83-
taskStatus: TaskDataStatus.FAILED
84-
});
85-
continue;
86-
}
87-
88-
if (status?.status === "ABORTED") {
89-
await context.tasks.updateTask(item.id, {
90-
taskStatus: TaskDataStatus.ABORTED
91-
});
60+
// Fetch all locales for the tenant.
61+
const locales = context.i18n.getLocales();
62+
await context.i18n.withEachLocale(locales, async () => {
63+
if (isCloseToTimeout()) {
64+
shouldContinue = true;
65+
return;
9266
}
93-
}
9467

95-
return response.continue(
96-
{
97-
...input
98-
},
99-
{
100-
seconds: 3600
101-
}
102-
);
103-
}
68+
// List all non-private models for the current locale.
69+
const models = await context.security.withoutAuthorization(async () =>
70+
(await context.cms.listModels()).filter(m => !m.isPrivate)
71+
);
10472

105-
try {
106-
const locales = context.i18n.getLocales();
73+
// Process each model to delete trashed entries.
74+
for (const model of models) {
75+
const list = createListDeletedEntries(context); // List trashed entries.
76+
const mutation = createDeleteEntry(context); // Mutation to delete entries.
10777

108-
await context.i18n.withEachLocale(locales, async () => {
109-
const models = await context.security.withoutAuthorization(async () => {
110-
return (await context.cms.listModels()).filter(model => !model.isPrivate);
111-
});
78+
// Query parameters for fetching deleted entries older than a minute ago.
79+
const listEntriesParams = {
80+
where: { deletedOn_lt: calculateDateTimeString() },
81+
limit: 50
82+
};
11283

113-
for (const model of models) {
114-
await trigger<IBulkActionOperationByModelInput>({
115-
name: `Headless CMS - Empty trash bin for "${model.name}" model.`,
116-
definition: "hcmsBulkListDeleteEntries",
117-
input: {
118-
modelId: model.modelId,
119-
where: {
120-
deletedOn_lt: calculateDateTimeString()
84+
let result;
85+
// Continue deleting entries while there are entries left to delete.
86+
while (
87+
(result = await list.execute(model.modelId, listEntriesParams)) &&
88+
result.meta.totalCount > 0
89+
) {
90+
if (isCloseToTimeout()) {
91+
shouldContinue = true;
92+
break;
93+
}
94+
for (const entry of result.entries) {
95+
if (isCloseToTimeout()) {
96+
shouldContinue = true;
97+
break;
12198
}
99+
// Delete each entry individually.
100+
await mutation.execute(model, entry.id);
122101
}
123-
});
102+
}
124103
}
125104
});
126105

127-
return response.continue(
128-
{
129-
triggered: true
130-
},
131-
{
132-
seconds: 120
133-
}
134-
);
135-
} catch (ex) {
136-
return response.error(ex.message ?? "Error while executing EmptyTrashBins task");
137-
}
138-
},
139-
onMaxIterations: cleanup,
140-
onDone: cleanup,
141-
onError: cleanup,
142-
onAbort: cleanup
106+
// If the task isn't continuing, add the tenant to the executed list.
107+
if (!shouldContinue) {
108+
executedTenantIds.push(tenant.id);
109+
}
110+
});
111+
112+
// Continue the task or mark it as done based on the `shouldContinue` flag.
113+
return shouldContinue
114+
? response.continue({ ...input, executedTenantIds })
115+
: response.done("Task done: emptied the trash bin for all registered models.");
116+
}
143117
});
144118
};

packages/api-headless-cms-bulk-actions/src/types.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@ import { CmsContext } from "@webiny/api-headless-cms/types";
22
import { Context as BaseContext } from "@webiny/handler/types";
33
import {
44
Context as TasksContext,
5-
ITaskOnAbortParams,
6-
ITaskOnErrorParams,
7-
ITaskOnMaxIterationsParams,
8-
ITaskOnSuccessParams,
95
ITaskResponseDoneResultOutput,
106
ITaskRunParams
117
} from "@webiny/tasks/types";
@@ -70,11 +66,17 @@ export type IBulkActionOperationByModelTaskParams = ITaskRunParams<
7066
>;
7167

7268
/**
73-
* Trash Bin
69+
* Empty Trash Bin
7470
*/
7571

76-
export type TrashBinCleanUpParams =
77-
| ITaskOnSuccessParams<HcmsBulkActionsContext>
78-
| ITaskOnErrorParams<HcmsBulkActionsContext>
79-
| ITaskOnAbortParams<HcmsBulkActionsContext>
80-
| ITaskOnMaxIterationsParams<HcmsBulkActionsContext>;
72+
export interface IEmptyTrashBinsInput {
73+
executedTenantIds?: string[] | null;
74+
}
75+
76+
export type IEmptyTrashBinsOutput = ITaskResponseDoneResultOutput;
77+
78+
export type IEmptyTrashBinsTaskParams = ITaskRunParams<
79+
HcmsBulkActionsContext,
80+
IEmptyTrashBinsInput,
81+
IEmptyTrashBinsOutput
82+
>;

packages/api-headless-cms-import-export/src/graphql/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,20 @@ import type { NonEmptyArray } from "@webiny/api/types";
77
import { CmsModel } from "@webiny/api-headless-cms/types";
88

99
export const attachHeadlessCmsImportExportGraphQL = async (context: Context): Promise<void> => {
10+
const tenant = context.tenancy.getCurrentTenant();
11+
const locale = context.i18n.getContentLocale();
1012
const models = await listModels(context);
1113

12-
if (models.length === 0) {
14+
if (!locale || models.length === 0) {
1315
return;
1416
}
1517

1618
const plugin = new CmsGraphQLSchemaPlugin<Context>({
1719
typeDefs: createTypeDefs(models as NonEmptyArray<CmsModel>),
18-
resolvers: createResolvers(models as NonEmptyArray<CmsModel>)
20+
resolvers: createResolvers(models as NonEmptyArray<CmsModel>),
21+
isApplicable: context =>
22+
context.tenancy.getCurrentTenant().id === tenant.id &&
23+
context.i18n.getContentLocale()?.code === locale.code
1924
});
2025

2126
plugin.name = "headlessCms.graphql.importExport";

packages/api-headless-cms/src/crud/contentModel/validateModelFields.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ const createGraphQLSchema = async (params: CreateGraphQLSchemaParams): Promise<a
232232

233233
const plugins = context.plugins
234234
.byType<ICmsGraphQLSchemaPlugin>(CmsGraphQLSchemaPlugin.type)
235+
.filter(plugin => plugin.isApplicable(context))
235236
.reduce<Record<string, ICmsGraphQLSchemaPlugin>>((collection, plugin) => {
236237
const name =
237238
plugin.name || `${CmsGraphQLSchemaPlugin.type}-${generateAlphaNumericId(16)}`;

packages/handler-graphql/src/plugins/GraphQLSchemaPlugin.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import { GraphQLSchemaDefinition, ResolverDecorators, Resolvers, TypeDefs } from
44

55
export interface IGraphQLSchemaPlugin<TContext = Context> extends Plugin {
66
schema: GraphQLSchemaDefinition<TContext>;
7+
isApplicable: (context: TContext) => boolean;
78
}
89

910
export interface GraphQLSchemaPluginConfig<TContext> {
1011
typeDefs?: TypeDefs;
1112
resolvers?: Resolvers<TContext>;
1213
resolverDecorators?: ResolverDecorators;
14+
isApplicable?: (context: TContext) => boolean;
1315
}
1416

1517
export class GraphQLSchemaPlugin<TContext = Context>
@@ -31,6 +33,13 @@ export class GraphQLSchemaPlugin<TContext = Context>
3133
resolverDecorators: this.config.resolverDecorators
3234
};
3335
}
36+
37+
isApplicable(context: TContext): boolean {
38+
if (this.config.isApplicable) {
39+
return this.config.isApplicable(context);
40+
}
41+
return true;
42+
}
3443
}
3544

3645
export const createGraphQLSchemaPlugin = <T = Context>(config: GraphQLSchemaPluginConfig<T>) => {

0 commit comments

Comments
 (0)