Skip to content

Commit 33481ab

Browse files
committed
fix(workflow-engine-*): Cleanup expired executions
1 parent b116f75 commit 33481ab

4 files changed

Lines changed: 180 additions & 17 deletions

File tree

packages/core/orchestration/src/transaction/datastore/abstract-storage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export interface IDistributedTransactionStorage {
5656
transaction: DistributedTransactionType,
5757
step: TransactionStep
5858
): Promise<void>
59+
clearExpiredExecutions(): Promise<void>
5960
}
6061

6162
export abstract class DistributedSchedulerStorage

packages/modules/workflow-engine-redis/integration-tests/__tests__/index.spec.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,6 +1107,120 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
11071107
})
11081108
})
11091109
})
1110+
1111+
describe("Cleaner job", function () {
1112+
it("should remove expired executions of finished workflows and keep the others", async () => {
1113+
const doneWorkflowId = "done-workflow-" + ulid()
1114+
createWorkflow({ name: doneWorkflowId, retentionTime: 1 }, () => {
1115+
return new WorkflowResponse("done")
1116+
})
1117+
1118+
const failingWorkflowId = "failing-workflow-" + ulid()
1119+
const failingStep = createStep("failing-step", () => {
1120+
throw new Error("I am failing")
1121+
})
1122+
createWorkflow({ name: failingWorkflowId, retentionTime: 1 }, () => {
1123+
failingStep()
1124+
})
1125+
1126+
const revertingStep = createStep(
1127+
"reverting-step",
1128+
() => {
1129+
throw new Error("I am reverting")
1130+
},
1131+
() => {
1132+
return new StepResponse("reverted")
1133+
}
1134+
)
1135+
1136+
const revertingWorkflowId = "reverting-workflow-" + ulid()
1137+
createWorkflow(
1138+
{ name: revertingWorkflowId, retentionTime: 1 },
1139+
() => {
1140+
revertingStep()
1141+
return new WorkflowResponse("reverted")
1142+
}
1143+
)
1144+
1145+
const runningWorkflowId = "running-workflow-" + ulid()
1146+
const longRunningStep = createStep("long-running-step", async () => {
1147+
await setTimeout(10000)
1148+
return new StepResponse("long running finished")
1149+
})
1150+
createWorkflow({ name: runningWorkflowId, retentionTime: 1 }, () => {
1151+
longRunningStep().config({ async: true, backgroundExecution: true })
1152+
return new WorkflowResponse("running workflow started")
1153+
})
1154+
1155+
const notExpiredWorkflowId = "not-expired-workflow-" + ulid()
1156+
createWorkflow(
1157+
{ name: notExpiredWorkflowId, retentionTime: 1000 },
1158+
() => {
1159+
return new WorkflowResponse("not expired")
1160+
}
1161+
)
1162+
1163+
const trx_done = "trx-done-" + ulid()
1164+
const trx_failed = "trx-failed-" + ulid()
1165+
const trx_reverting = "trx-reverting-" + ulid()
1166+
const trx_running = "trx-running-" + ulid()
1167+
const trx_not_expired = "trx-not-expired-" + ulid()
1168+
1169+
// run workflows
1170+
await workflowOrcModule.run(doneWorkflowId, {
1171+
transactionId: trx_done,
1172+
})
1173+
1174+
await workflowOrcModule.run(failingWorkflowId, {
1175+
transactionId: trx_failed,
1176+
throwOnError: false,
1177+
})
1178+
1179+
await workflowOrcModule.run(revertingWorkflowId, {
1180+
transactionId: trx_reverting,
1181+
throwOnError: false,
1182+
})
1183+
1184+
await workflowOrcModule.run(runningWorkflowId, {
1185+
transactionId: trx_running,
1186+
})
1187+
1188+
await workflowOrcModule.run(notExpiredWorkflowId, {
1189+
transactionId: trx_not_expired,
1190+
})
1191+
1192+
let executions = await workflowOrcModule.listWorkflowExecutions()
1193+
expect(executions).toHaveLength(5)
1194+
1195+
await setTimeout(2000)
1196+
1197+
// Manually trigger cleaner
1198+
await (workflowOrcModule as any).workflowOrchestratorService_[
1199+
"redisDistributedTransactionStorage_"
1200+
]["clearExpiredExecutions"]()
1201+
1202+
let remainingExecutions =
1203+
await workflowOrcModule.listWorkflowExecutions()
1204+
1205+
expect(remainingExecutions).toHaveLength(2)
1206+
1207+
const remainingTrxIds = remainingExecutions
1208+
.map((e) => e.transaction_id)
1209+
.sort()
1210+
1211+
expect(remainingTrxIds).toEqual([trx_not_expired, trx_running].sort())
1212+
1213+
const notExpiredExec = remainingExecutions.find(
1214+
(e) => e.transaction_id === trx_not_expired
1215+
)
1216+
expect(notExpiredExec?.state).toBe(TransactionState.DONE)
1217+
1218+
const runningExec = remainingExecutions.find(
1219+
(e) => e.transaction_id === trx_running
1220+
)
1221+
expect(runningExec?.state).toBe(TransactionState.INVOKING)
1222+
})
1223+
})
11101224
})
11111225
},
11121226
})

packages/modules/workflow-engine-redis/src/services/workflows-module.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ export class WorkflowsModuleService<
4747
protected workflowOrchestratorService_: WorkflowOrchestratorService
4848
protected redisDisconnectHandler_: () => Promise<void>
4949
protected manager_: SqlEntityManager
50-
private clearTimeout_: NodeJS.Timeout
5150

5251
constructor(
5352
{
@@ -72,21 +71,13 @@ export class WorkflowsModuleService<
7271
__hooks = {
7372
onApplicationStart: async () => {
7473
await this.workflowOrchestratorService_.onApplicationStart()
75-
76-
await this.clearExpiredExecutions()
77-
this.clearTimeout_ = setInterval(async () => {
78-
try {
79-
await this.clearExpiredExecutions()
80-
} catch {}
81-
}, 1000 * 60 * 60)
8274
},
8375
onApplicationPrepareShutdown: async () => {
8476
await this.workflowOrchestratorService_.onApplicationPrepareShutdown()
8577
},
8678
onApplicationShutdown: async () => {
8779
await this.workflowOrchestratorService_.onApplicationShutdown()
8880
await this.redisDisconnectHandler_()
89-
clearInterval(this.clearTimeout_)
9081
},
9182
}
9283

@@ -301,14 +292,6 @@ export class WorkflowsModuleService<
301292
return this.workflowOrchestratorService_.unsubscribe(args as any)
302293
}
303294

304-
private async clearExpiredExecutions() {
305-
return this.manager_.execute(`
306-
DELETE FROM workflow_execution
307-
WHERE retention_time IS NOT NULL AND
308-
updated_at <= (CURRENT_TIMESTAMP - INTERVAL '1 second' * retention_time);
309-
`)
310-
}
311-
312295
@InjectSharedContext()
313296
async cancel(
314297
workflowId: string,

packages/modules/workflow-engine-redis/src/utils/workflow-orchestrator-storage.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
TransactionState,
2323
TransactionStepState,
2424
} from "@medusajs/framework/utils"
25+
import { raw } from "@mikro-orm/core"
2526
import { WorkflowOrchestratorService } from "@services"
2627
import { Queue, RepeatOptions, Worker } from "bullmq"
2728
import Redis from "ioredis"
@@ -33,6 +34,9 @@ enum JobType {
3334
TRANSACTION_TIMEOUT = "transaction_timeout",
3435
}
3536

37+
const ONE_HOUR_IN_MS = 1000 * 60 * 60
38+
const REPEATABLE_CLEARER_JOB_ID = "clear-expired-executions"
39+
3640
export class RedisDistributedTransactionStorage
3741
implements IDistributedTransactionStorage, IDistributedSchedulerStorage
3842
{
@@ -48,6 +52,9 @@ export class RedisDistributedTransactionStorage
4852
private jobQueue?: Queue
4953
private worker: Worker
5054
private jobWorker?: Worker
55+
private cleanerQueueName: string
56+
private cleanerWorker_: Worker
57+
private cleanerQueue_?: Queue
5158

5259
#isWorkerMode: boolean = false
5360

@@ -72,6 +79,7 @@ export class RedisDistributedTransactionStorage
7279
this.logger_ = logger
7380
this.redisClient = redisConnection
7481
this.redisWorkerConnection = redisWorkerConnection
82+
this.cleanerQueueName = "workflows-cleaner"
7583
this.queueName = redisQueueName
7684
this.jobQueueName = redisJobQueueName
7785
this.queue = new Queue(redisQueueName, { connection: this.redisClient })
@@ -80,18 +88,33 @@ export class RedisDistributedTransactionStorage
8088
connection: this.redisClient,
8189
})
8290
: undefined
91+
this.cleanerQueue_ = isWorkerMode
92+
? new Queue(this.cleanerQueueName, {
93+
connection: this.redisClient,
94+
})
95+
: undefined
8396
this.#isWorkerMode = isWorkerMode
8497
}
8598

8699
async onApplicationPrepareShutdown() {
87100
// Close worker gracefully, i.e. wait for the current jobs to finish
88101
await this.worker?.close()
89102
await this.jobWorker?.close()
103+
104+
const repeatableJobs = (await this.cleanerQueue_?.getRepeatableJobs()) ?? []
105+
for (const job of repeatableJobs) {
106+
if (job.id === REPEATABLE_CLEARER_JOB_ID) {
107+
await this.cleanerQueue_?.removeRepeatableByKey(job.key)
108+
}
109+
}
110+
111+
await this.cleanerWorker_?.close()
90112
}
91113

92114
async onApplicationShutdown() {
93115
await this.queue?.close()
94116
await this.jobQueue?.close()
117+
await this.cleanerQueue_?.close()
95118
}
96119

97120
async onApplicationStart() {
@@ -151,6 +174,27 @@ export class RedisDistributedTransactionStorage
151174
},
152175
workerOptions
153176
)
177+
178+
this.cleanerWorker_ = new Worker(
179+
this.cleanerQueueName,
180+
async () => {
181+
await this.clearExpiredExecutions()
182+
},
183+
{ connection: this.redisClient }
184+
)
185+
186+
await this.cleanerQueue_?.add(
187+
"cleaner",
188+
{},
189+
{
190+
repeat: {
191+
every: ONE_HOUR_IN_MS,
192+
},
193+
jobId: REPEATABLE_CLEARER_JOB_ID,
194+
removeOnComplete: true,
195+
removeOnFail: true,
196+
}
197+
)
154198
}
155199
}
156200

@@ -728,4 +772,25 @@ export class RedisDistributedTransactionStorage
728772
throw new SkipExecutionError("Already finished by another execution")
729773
}
730774
}
775+
776+
private async clearExpiredExecutions() {
777+
await this.workflowExecutionService_.delete({
778+
retention_time: {
779+
$ne: null,
780+
},
781+
updated_at: {
782+
$lte: raw(
783+
(alias) =>
784+
`CURRENT_TIMESTAMP - (INTERVAL '1 second' * retention_time)`
785+
),
786+
},
787+
state: {
788+
$in: [
789+
TransactionState.DONE,
790+
TransactionState.FAILED,
791+
TransactionState.REVERTED,
792+
],
793+
},
794+
})
795+
}
731796
}

0 commit comments

Comments
 (0)