feat: Workflow engine modules (#6128)

This commit is contained in:
Carlos R. L. Rodrigues authored and GitHub committed 2024-01-23 10:08:08 -03:00
1 parent d85fee42ee
commit 302323916b
119 files changed
+5339 -263

No files matched your search

@@ -1,10 +1,14 @@
import { TransactionStepState, TransactionStepStatus } from "@medusajs/utils"
import { setTimeout } from "timers/promises"
import {
DistributedTransaction,
TransactionHandlerType,
TransactionOrchestrator,
TransactionPayload,
TransactionState,
TransactionStepTimeoutError,
TransactionStepsDefinition,
TransactionTimeoutError,
} from "../../transaction"
describe("Transaction Orchestrator", () => {
@@ -986,4 +990,454 @@ describe("Transaction Orchestrator", () => {
expect(transaction).toBe(transactionInHandler)
})
describe("Timeouts - Transaction and Step", () => {
it("should fail the current steps and revert the transaction if the Transaction Timeout is reached", async () => {
const mocks = {
f1: jest.fn(() => {
return "content f1"
}),
f2: jest.fn(async () => {
await setTimeout(200)
return "delayed content f2"
}),
f3: jest.fn(() => {
return "content f3"
}),
f4: jest.fn(() => {
return "content f4"
}),
}
async function handler(
actionId: string,
functionHandlerType: TransactionHandlerType,
payload: TransactionPayload
) {
const command = {
action1: {
[TransactionHandlerType.INVOKE]: () => {
return mocks.f1()
},
[TransactionHandlerType.COMPENSATE]: () => {
return mocks.f1()
},
},
action2: {
[TransactionHandlerType.INVOKE]: async () => {
return await mocks.f2()
},
[TransactionHandlerType.COMPENSATE]: () => {
return mocks.f2()
},
},
action3: {
[TransactionHandlerType.INVOKE]: () => {
return mocks.f3()
},
[TransactionHandlerType.COMPENSATE]: () => {
return mocks.f3()
},
},
action4: {
[TransactionHandlerType.INVOKE]: () => {
return mocks.f4()
},
[TransactionHandlerType.COMPENSATE]: () => {
return mocks.f4()
},
},
}
return command[actionId][functionHandlerType]()
}
const flow: TransactionStepsDefinition = {
next: {
action: "action1",
next: [
{
action: "action2",
},
{
action: "action3",
next: {
action: "action4",
},
},
],
},
}
const strategy = new TransactionOrchestrator("transaction-name", flow, {
timeout: 0.1, // 100ms
})
const transaction = await strategy.beginTransaction(
"transaction_id_123",
handler
)
await strategy.resume(transaction)
expect(transaction.transactionId).toBe("transaction_id_123")
expect(mocks.f1).toBeCalledTimes(2)
expect(mocks.f2).toBeCalledTimes(2)
expect(mocks.f3).toBeCalledTimes(2)
expect(mocks.f4).toBeCalledTimes(0)
expect(transaction.getContext().invoke.action1).toBe("content f1")
expect(transaction.getContext().invoke.action2).toBe("delayed content f2")
expect(transaction.getContext().invoke.action3).toBe("content f3")
expect(transaction.getContext().invoke.action4).toBe(undefined)
expect(transaction.getErrors()[0].error).toBeInstanceOf(
TransactionTimeoutError
)
expect(transaction.getErrors()[0].action).toBe("action2")
expect(transaction.getState()).toBe(TransactionState.REVERTED)
})
it("should continue the transaction and skip children steps when the Transaction Step Timeout is reached but the step is set to 'continueOnPermanentFailure'", async () => {
const mocks = {
f1: jest.fn(() => {
return "content f1"
}),
f2: jest.fn(async () => {
await setTimeout(200)
return "delayed content f2"
}),
f3: jest.fn(() => {
return "content f3"
}),
f4: jest.fn(() => {
return "content f4"
}),
}
async function handler(
actionId: string,
functionHandlerType: TransactionHandlerType,
payload: TransactionPayload
) {
const command = {
action1: {
[TransactionHandlerType.INVOKE]: () => {
return mocks.f1()
},
[TransactionHandlerType.COMPENSATE]: () => {
return mocks.f1()
},
},
action2: {
[TransactionHandlerType.INVOKE]: async () => {
return await mocks.f2()
},
[TransactionHandlerType.COMPENSATE]: () => {
return mocks.f2()
},
},
action3: {
[TransactionHandlerType.INVOKE]: () => {
return mocks.f3()
},
[TransactionHandlerType.COMPENSATE]: () => {
return mocks.f3()
},
},
action4: {
[TransactionHandlerType.INVOKE]: () => {
return mocks.f4()
},
[TransactionHandlerType.COMPENSATE]: () => {
return mocks.f4()
},
},
}
return command[actionId][functionHandlerType]()
}
const flow: TransactionStepsDefinition = {
next: {
action: "action1",
next: [
{
timeout: 0.1, // 100ms
action: "action2",
continueOnPermanentFailure: true,
next: {
action: "action4",
},
},
{
action: "action3",
},
],
},
}
const strategy = new TransactionOrchestrator("transaction-name", flow)
const transaction = await strategy.beginTransaction(
"transaction_id_123",
handler
)
await strategy.resume(transaction)
expect(transaction.transactionId).toBe("transaction_id_123")
expect(mocks.f1).toBeCalledTimes(1)
expect(mocks.f2).toBeCalledTimes(1)
expect(mocks.f3).toBeCalledTimes(1)
expect(mocks.f4).toBeCalledTimes(0)
expect(transaction.getContext().invoke.action1).toBe("content f1")
expect(transaction.getContext().invoke.action2).toBe("delayed content f2")
expect(transaction.getContext().invoke.action3).toBe("content f3")
expect(transaction.getContext().invoke.action4).toBe(undefined)
expect(
transaction.getFlow().steps["_root.action1.action2"].invoke.state
).toBe(TransactionStepState.TIMEOUT)
expect(
transaction.getFlow().steps["_root.action1.action2"].invoke.status
).toBe(TransactionStepStatus.PERMANENT_FAILURE)
expect(
transaction.getFlow().steps["_root.action1.action2"].compensate.state
).toBe(TransactionStepState.DORMANT)
expect(
transaction.getFlow().steps["_root.action1.action2.action4"].invoke
.state
).toBe(TransactionStepState.SKIPPED)
expect(
transaction.getFlow().steps["_root.action1.action2.action4"].invoke
.status
).toBe(TransactionStepStatus.IDLE)
expect(transaction.getState()).toBe(TransactionState.DONE)
})
it("should fail the current steps and revert the transaction if the Step Timeout is reached", async () => {
const mocks = {
f1: jest.fn(() => {
return "content f1"
}),
f2: jest.fn(async () => {
await setTimeout(200)
return "delayed content f2"
}),
f3: jest.fn(() => {
return "content f3"
}),
f4: jest.fn(() => {
return "content f4"
}),
}
async function handler(
actionId: string,
functionHandlerType: TransactionHandlerType,
payload: TransactionPayload
) {
const command = {
action1: {
[TransactionHandlerType.INVOKE]: () => {
return mocks.f1()
},
[TransactionHandlerType.COMPENSATE]: () => {
return mocks.f1()
},
},
action2: {
[TransactionHandlerType.INVOKE]: async () => {
return await mocks.f2()
},
[TransactionHandlerType.COMPENSATE]: () => {
return mocks.f2()
},
},
action3: {
[TransactionHandlerType.INVOKE]: () => {
return mocks.f3()
},
[TransactionHandlerType.COMPENSATE]: () => {
return mocks.f3()
},
},
action4: {
[TransactionHandlerType.INVOKE]: () => {
return mocks.f4()
},
[TransactionHandlerType.COMPENSATE]: () => {
return mocks.f4()
},
},
}
return command[actionId][functionHandlerType]()
}
const flow: TransactionStepsDefinition = {
next: {
action: "action1",
next: [
{
action: "action2",
timeout: 0.1, // 100ms
},
{
action: "action3",
next: {
action: "action4",
},
},
],
},
}
const strategy = new TransactionOrchestrator("transaction-name", flow)
const transaction = await strategy.beginTransaction(
"transaction_id_123",
handler
)
await strategy.resume(transaction)
expect(transaction.transactionId).toBe("transaction_id_123")
expect(mocks.f1).toBeCalledTimes(2)
expect(mocks.f2).toBeCalledTimes(2)
expect(mocks.f3).toBeCalledTimes(2)
expect(mocks.f4).toBeCalledTimes(0)
expect(transaction.getContext().invoke.action1).toBe("content f1")
expect(transaction.getContext().invoke.action2).toBe("delayed content f2")
expect(transaction.getContext().invoke.action3).toBe("content f3")
expect(transaction.getContext().invoke.action4).toBe(undefined)
expect(transaction.getErrors()[0].error).toBeInstanceOf(
TransactionStepTimeoutError
)
expect(transaction.getErrors()[0].action).toBe("action2")
expect(transaction.getState()).toBe(TransactionState.REVERTED)
})
it("should fail the current steps and revert the transaction if the Transaction Timeout is reached event if the step is set as 'continueOnPermanentFailure'", async () => {
const mocks = {
f1: jest.fn(() => {
return "content f1"
}),
f2: jest.fn(async () => {
await setTimeout(200)
return "delayed content f2"
}),
f3: jest.fn(async () => {
await setTimeout(200)
return "content f3"
}),
f4: jest.fn(() => {
return "content f4"
}),
}
async function handler(
actionId: string,
functionHandlerType: TransactionHandlerType,
payload: TransactionPayload
) {
const command = {
action1: {
[TransactionHandlerType.INVOKE]: () => {
return mocks.f1()
},
[TransactionHandlerType.COMPENSATE]: () => {
return mocks.f1()
},
},
action2: {
[TransactionHandlerType.INVOKE]: async () => {
return await mocks.f2()
},
[TransactionHandlerType.COMPENSATE]: () => {
return mocks.f2()
},
},
action3: {
[TransactionHandlerType.INVOKE]: async () => {
return await mocks.f3()
},
[TransactionHandlerType.COMPENSATE]: () => {
return mocks.f3()
},
},
action4: {
[TransactionHandlerType.INVOKE]: () => {
return mocks.f4()
},
[TransactionHandlerType.COMPENSATE]: () => {
return mocks.f4()
},
},
}
return command[actionId][functionHandlerType]()
}
const flow: TransactionStepsDefinition = {
next: {
action: "action1",
next: [
{
action: "action2",
continueOnPermanentFailure: true,
},
{
action: "action3",
continueOnPermanentFailure: true,
next: {
action: "action4",
},
},
],
},
}
const strategy = new TransactionOrchestrator("transaction-name", flow, {
timeout: 0.1, // 100ms
})
const transaction = await strategy.beginTransaction(
"transaction_id_123",
handler
)
await strategy.resume(transaction)
expect(transaction.transactionId).toBe("transaction_id_123")
expect(mocks.f1).toBeCalledTimes(2)
expect(mocks.f2).toBeCalledTimes(2)
expect(mocks.f3).toBeCalledTimes(2)
expect(mocks.f4).toBeCalledTimes(0)
expect(transaction.getContext().invoke.action1).toBe("content f1")
expect(transaction.getContext().invoke.action2).toBe("delayed content f2")
expect(transaction.getContext().invoke.action3).toBe("content f3")
expect(transaction.getContext().invoke.action4).toBe(undefined)
expect(transaction.getErrors()).toHaveLength(2)
expect(
TransactionTimeoutError.isTransactionTimeoutError(
transaction.getErrors()[0].error
)
).toBe(true)
expect(transaction.getErrors()[0].action).toBe("action2")
expect(
TransactionTimeoutError.isTransactionTimeoutError(
transaction.getErrors()[1].error
)
).toBe(true)
expect(transaction.getErrors()[1].action).toBe("action3")
expect(transaction.getState()).toBe(TransactionState.REVERTED)
})
})
})
@@ -3,14 +3,11 @@ import {
TransactionCheckpoint,
} from "../distributed-transaction"
import { TransactionStep } from "../transaction-step"
import { TransactionModelOptions } from "../types"
export interface IDistributedTransactionStorage {
get(key: string): Promise<TransactionCheckpoint | undefined>
list(): Promise<TransactionCheckpoint[]>
save(key: string, data: TransactionCheckpoint, ttl?: number): Promise<void>
delete(key: string): Promise<void>
archive(key: string, options?: TransactionModelOptions): Promise<void>
scheduleRetry(
transaction: DistributedTransaction,
step: TransactionStep,
@@ -62,14 +59,6 @@ export abstract class DistributedTransactionStorage
throw new Error("Method 'save' not implemented.")
}
async delete(key: string): Promise<void> {
throw new Error("Method 'delete' not implemented.")
}
async archive(key: string, options?: TransactionModelOptions): Promise<void> {
throw new Error("Method 'archive' not implemented.")
}
async scheduleRetry(
transaction: DistributedTransaction,
step: TransactionStep,
@@ -1,5 +1,5 @@
import { TransactionState } from "@medusajs/utils"
import { TransactionCheckpoint } from "../distributed-transaction"
import { TransactionModelOptions } from "../types"
import { DistributedTransactionStorage } from "./abstract-storage"
// eslint-disable-next-line max-len
@@ -24,14 +24,16 @@ export class BaseInMemoryDistributedTransactionStorage extends DistributedTransa
data: TransactionCheckpoint,
ttl?: number
): Promise<void> {
this.storage.set(key, data)
}
const hasFinished = [
TransactionState.DONE,
TransactionState.REVERTED,
TransactionState.FAILED,
].includes(data.flow.state)
async delete(key: string): Promise<void> {
this.storage.delete(key)
}
async archive(key: string, options?: TransactionModelOptions): Promise<void> {
this.storage.delete(key)
if (hasFinished) {
this.storage.delete(key)
} else {
this.storage.set(key, data)
}
}
}
@@ -86,7 +86,7 @@ export class DistributedTransaction extends EventEmitter {
this.keyValueStore = storage
}
private static keyPrefix = "dtrans"
public static keyPrefix = "dtrans"
constructor(
private flow: TransactionFlow,
@@ -177,18 +177,18 @@ export class DistributedTransaction extends EventEmitter {
}
public hasTimeout(): boolean {
return !!this.getFlow().definition.timeout
return !!this.getTimeout()
}
public getTimeoutInterval(): number | undefined {
return this.getFlow().definition.timeout
public getTimeout(): number | undefined {
return this.getFlow().options?.timeout
}
public async saveCheckpoint(
ttl = 0
): Promise<TransactionCheckpoint | undefined> {
const options = this.getFlow().options
if (!options?.storeExecution) {
if (!options?.store) {
return
}
@@ -226,31 +226,6 @@ export class DistributedTransaction extends EventEmitter {
return null
}
public async deleteCheckpoint(): Promise<void> {
const options = this.getFlow().options
if (!options?.storeExecution) {
return
}
const key = TransactionOrchestrator.getKeyName(
DistributedTransaction.keyPrefix,
this.modelId,
this.transactionId
)
await DistributedTransaction.keyValueStore.delete(key)
}
public async archiveCheckpoint(): Promise<void> {
const options = this.getFlow().options
const key = TransactionOrchestrator.getKeyName(
DistributedTransaction.keyPrefix,
this.modelId,
this.transactionId
)
await DistributedTransaction.keyValueStore.archive(key, options)
}
public async scheduleRetry(
step: TransactionStep,
interval: number
@@ -269,6 +244,11 @@ export class DistributedTransaction extends EventEmitter {
}
public async scheduleTransactionTimeout(interval: number): Promise<void> {
// schedule transaction timeout only if there are async steps
if (!this.getFlow().hasAsyncSteps) {
return
}
await this.saveCheckpoint()
await DistributedTransaction.keyValueStore.scheduleTransactionTimeout(
this,
@@ -278,6 +258,10 @@ export class DistributedTransaction extends EventEmitter {
}
public async clearTransactionTimeout(): Promise<void> {
if (!this.getFlow().hasAsyncSteps) {
return
}
await DistributedTransaction.keyValueStore.clearTransactionTimeout(this)
}
@@ -285,6 +269,11 @@ export class DistributedTransaction extends EventEmitter {
step: TransactionStep,
interval: number
): Promise<void> {
// schedule step timeout only if the step is async
if (!step.definition.async) {
return
}
await this.saveCheckpoint()
await DistributedTransaction.keyValueStore.scheduleStepTimeout(
this,
@@ -295,6 +284,10 @@ export class DistributedTransaction extends EventEmitter {
}
public async clearStepTimeout(step: TransactionStep): Promise<void> {
if (!step.definition.async || step.isCompensating()) {
return
}
await DistributedTransaction.keyValueStore.clearStepTimeout(this, step)
}
}
@@ -4,7 +4,7 @@ export class PermanentStepFailureError extends Error {
): error is PermanentStepFailureError {
return (
error instanceof PermanentStepFailureError ||
error.name === "PermanentStepFailure"
error?.name === "PermanentStepFailure"
)
}
@@ -14,16 +14,19 @@ export class PermanentStepFailureError extends Error {
}
}
export class StepTimeoutError extends Error {
static isStepTimeoutError(error: Error): error is StepTimeoutError {
export class TransactionStepTimeoutError extends Error {
static isTransactionStepTimeoutError(
error: Error
): error is TransactionStepTimeoutError {
return (
error instanceof StepTimeoutError || error.name === "StepTimeoutError"
error instanceof TransactionStepTimeoutError ||
error?.name === "TransactionStepTimeoutError"
)
}
constructor(message?: string) {
super(message)
this.name = "StepTimeoutError"
this.name = "TransactionStepTimeoutError"
}
}
@@ -33,7 +36,7 @@ export class TransactionTimeoutError extends Error {
): error is TransactionTimeoutError {
return (
error instanceof TransactionTimeoutError ||
error.name === "TransactionTimeoutError"
error?.name === "TransactionTimeoutError"
)
}
@@ -314,7 +314,7 @@ export class OrchestratorBuilder {
action: string,
step: InternalStep = this.steps
): InternalStep | undefined {
if (step.action === action) {
if (step.uuid === action || step.action === action) {
return step
}
@@ -357,7 +357,7 @@ export class OrchestratorBuilder {
if (!nextStep) {
continue
}
if (nextStep.action === action) {
if (nextStep.uuid === action || nextStep.action === action) {
return step
}
const foundStep = this.findParentStepByAction(
@@ -13,11 +13,11 @@ import {
TransactionStepStatus,
} from "./types"
import { MedusaError, promiseAll } from "@medusajs/utils"
import { MedusaError, promiseAll, TransactionStepState } from "@medusajs/utils"
import { EventEmitter } from "events"
import {
PermanentStepFailureError,
StepTimeoutError,
TransactionStepTimeoutError,
TransactionTimeoutError,
} from "./errors"
@@ -30,6 +30,7 @@ export type TransactionFlow = {
hasFailedSteps: boolean
hasWaitingSteps: boolean
hasSkippedSteps: boolean
hasRevertedSteps: boolean
timedOutAt: number | null
startedAt?: number
state: TransactionState
@@ -62,10 +63,6 @@ export class TransactionOrchestrator extends EventEmitter {
return params.join(this.SEPARATOR)
}
public getOptions(): TransactionModelOptions {
return this.options ?? {}
}
private getPreviousStep(flow: TransactionFlow, step: TransactionStep) {
const id = step.id.split(".")
id.pop()
@@ -73,6 +70,10 @@ export class TransactionOrchestrator extends EventEmitter {
return flow.steps[parentId]
}
public getOptions(): TransactionModelOptions {
return this.options ?? {}
}
private getInvokeSteps(flow: TransactionFlow): string[] {
if (this.invokeSteps.length) {
return this.invokeSteps
@@ -102,9 +103,10 @@ export class TransactionOrchestrator extends EventEmitter {
private canMoveForward(flow: TransactionFlow, previousStep: TransactionStep) {
const states = [
TransactionState.DONE,
TransactionState.FAILED,
TransactionState.SKIPPED,
TransactionStepState.DONE,
TransactionStepState.FAILED,
TransactionStepState.TIMEOUT,
TransactionStepState.SKIPPED,
]
const siblings = this.getPreviousStep(flow, previousStep).next.map(
@@ -119,10 +121,10 @@ export class TransactionOrchestrator extends EventEmitter {
private canMoveBackward(flow: TransactionFlow, step: TransactionStep) {
const states = [
TransactionState.DONE,
TransactionState.REVERTED,
TransactionState.FAILED,
TransactionState.DORMANT,
TransactionStepState.DONE,
TransactionStepState.REVERTED,
TransactionStepState.FAILED,
TransactionStepState.DORMANT,
]
const siblings = step.next.map((sib) => flow.steps[sib])
return (
@@ -144,29 +146,89 @@ export class TransactionOrchestrator extends EventEmitter {
}
}
private async checkStepTimeout(transaction, step) {
private hasExpired(
{
transaction,
step,
}: {
transaction?: DistributedTransaction
step?: TransactionStep
},
dateNow: number
): boolean {
const hasStepTimedOut =
step &&
step.hasTimeout() &&
!step.isCompensating() &&
dateNow > step.startedAt! + step.getTimeout()! * 1e3
const hasTransactionTimedOut =
transaction &&
transaction.hasTimeout() &&
transaction.getFlow().state !== TransactionState.COMPENSATING &&
dateNow >
transaction.getFlow().startedAt! + transaction.getTimeout()! * 1e3
return !!hasStepTimedOut || !!hasTransactionTimedOut
}
private async checkTransactionTimeout(
transaction: DistributedTransaction,
currentSteps: TransactionStep[]
) {
const flow = transaction.getFlow()
let hasTimedOut = false
if (!flow.timedOutAt && this.hasExpired({ transaction }, Date.now())) {
flow.timedOutAt = Date.now()
void transaction.clearTransactionTimeout()
for (const step of currentSteps) {
await TransactionOrchestrator.setStepTimeout(
transaction,
step,
new TransactionTimeoutError()
)
}
await transaction.saveCheckpoint()
this.emit(DistributedTransactionEvent.TIMEOUT, { transaction })
hasTimedOut = true
}
return hasTimedOut
}
private async checkStepTimeout(
transaction: DistributedTransaction,
step: TransactionStep
) {
let hasTimedOut = false
if (
step.hasTimeout() &&
!step.timedOutAt &&
step.canCancel() &&
step.startedAt! + step.getTimeoutInterval()! * 1e3 < Date.now()
this.hasExpired({ step }, Date.now())
) {
step.timedOutAt = Date.now()
await transaction.saveCheckpoint()
this.emit(DistributedTransactionEvent.TIMEOUT, { transaction })
await TransactionOrchestrator.setStepFailure(
await TransactionOrchestrator.setStepTimeout(
transaction,
step,
new StepTimeoutError(),
0
new TransactionStepTimeoutError()
)
hasTimedOut = true
await transaction.saveCheckpoint()
this.emit(DistributedTransactionEvent.TIMEOUT, { transaction })
}
return hasTimedOut
}
private async checkAllSteps(transaction: DistributedTransaction): Promise<{
current: TransactionStep[]
next: TransactionStep[]
total: number
remaining: number
@@ -182,6 +244,8 @@ export class TransactionOrchestrator extends EventEmitter {
const flow = transaction.getFlow()
const nextSteps: TransactionStep[] = []
const currentSteps: TransactionStep[] = []
const allSteps =
flow.state === TransactionState.COMPENSATING
? this.getCompensationSteps(flow)
@@ -204,6 +268,7 @@ export class TransactionOrchestrator extends EventEmitter {
}
if (curState.status === TransactionStepStatus.WAITING) {
currentSteps.push(stepDef)
hasWaiting = true
if (stepDef.hasAwaitingRetry()) {
@@ -223,6 +288,8 @@ export class TransactionOrchestrator extends EventEmitter {
continue
} else if (curState.status === TransactionStepStatus.TEMPORARY_FAILURE) {
currentSteps.push(stepDef)
if (!stepDef.canRetry()) {
if (stepDef.hasRetryInterval() && !stepDef.retryRescheduledAt) {
stepDef.hasScheduledRetry = true
@@ -243,11 +310,11 @@ export class TransactionOrchestrator extends EventEmitter {
} else {
completedSteps++
if (curState.state === TransactionState.SKIPPED) {
if (curState.state === TransactionStepState.SKIPPED) {
hasSkipped = true
} else if (curState.state === TransactionState.REVERTED) {
} else if (curState.state === TransactionStepState.REVERTED) {
hasReverted = true
} else if (curState.state === TransactionState.FAILED) {
} else if (curState.state === TransactionStepState.FAILED) {
if (stepDef.definition.continueOnPermanentFailure) {
hasIgnoredFailure = true
} else {
@@ -258,6 +325,7 @@ export class TransactionOrchestrator extends EventEmitter {
}
flow.hasWaitingSteps = hasWaiting
flow.hasRevertedSteps = hasReverted
const totalSteps = allSteps.length - 1
if (
@@ -288,6 +356,7 @@ export class TransactionOrchestrator extends EventEmitter {
}
return {
current: currentSteps,
next: nextSteps,
total: totalSteps,
remaining: totalSteps - completedSteps,
@@ -304,11 +373,13 @@ export class TransactionOrchestrator extends EventEmitter {
const stepDef = flow.steps[step]
const curState = stepDef.getStates()
if (
curState.state === TransactionState.DONE ||
[TransactionStepState.DONE, TransactionStepState.TIMEOUT].includes(
curState.state
) ||
curState.status === TransactionStepStatus.PERMANENT_FAILURE
) {
stepDef.beginCompensation()
stepDef.changeState(TransactionState.NOT_STARTED)
stepDef.changeState(TransactionStepState.NOT_STARTED)
}
}
}
@@ -318,6 +389,9 @@ export class TransactionOrchestrator extends EventEmitter {
step: TransactionStep,
response: unknown
): Promise<void> {
const hasStepTimedOut =
step.getStates().state === TransactionStepState.TIMEOUT
if (step.saveResponse) {
transaction.addResponse(
step.definition.action!,
@@ -328,16 +402,19 @@ export class TransactionOrchestrator extends EventEmitter {
)
}
step.changeStatus(TransactionStepStatus.OK)
const flow = transaction.getFlow()
if (step.isCompensating()) {
step.changeState(TransactionState.REVERTED)
} else {
step.changeState(TransactionState.DONE)
if (!hasStepTimedOut) {
step.changeStatus(TransactionStepStatus.OK)
}
const flow = transaction.getFlow()
if (step.definition.async || flow.options?.strictCheckpoints) {
if (step.isCompensating()) {
step.changeState(TransactionStepState.REVERTED)
} else if (!hasStepTimedOut) {
step.changeState(TransactionStepState.DONE)
}
if (step.definition.async || flow.options?.storeExecution) {
await transaction.saveCheckpoint()
}
@@ -357,35 +434,87 @@ export class TransactionOrchestrator extends EventEmitter {
transaction.emit(eventName, { step, transaction })
}
private static async setStepTimeout(
transaction: DistributedTransaction,
step: TransactionStep,
error: TransactionStepTimeoutError | TransactionTimeoutError
): Promise<void> {
if (
[
TransactionStepState.TIMEOUT,
TransactionStepState.DONE,
TransactionStepState.REVERTED,
].includes(step.getStates().state)
) {
return
}
step.changeState(TransactionStepState.TIMEOUT)
transaction.addError(
step.definition.action!,
TransactionHandlerType.INVOKE,
error
)
await TransactionOrchestrator.setStepFailure(
transaction,
step,
undefined,
0,
true,
error
)
await transaction.clearStepTimeout(step)
}
private static async setStepFailure(
transaction: DistributedTransaction,
step: TransactionStep,
error: Error | any,
maxRetries: number = TransactionOrchestrator.DEFAULT_RETRIES
maxRetries: number = TransactionOrchestrator.DEFAULT_RETRIES,
isTimeout = false,
timeoutError?: TransactionStepTimeoutError | TransactionTimeoutError
): Promise<void> {
step.failures++
step.changeStatus(TransactionStepStatus.TEMPORARY_FAILURE)
if (
!isTimeout &&
step.getStates().status !== TransactionStepStatus.PERMANENT_FAILURE
) {
step.changeStatus(TransactionStepStatus.TEMPORARY_FAILURE)
}
const flow = transaction.getFlow()
const cleaningUp: Promise<unknown>[] = []
if (step.failures > maxRetries) {
step.changeState(TransactionState.FAILED)
const hasTimedOut = step.getStates().state === TransactionStepState.TIMEOUT
if (step.failures > maxRetries || hasTimedOut) {
if (!hasTimedOut) {
step.changeState(TransactionStepState.FAILED)
}
step.changeStatus(TransactionStepStatus.PERMANENT_FAILURE)
transaction.addError(
step.definition.action!,
step.isCompensating()
? TransactionHandlerType.COMPENSATE
: TransactionHandlerType.INVOKE,
error
)
if (!isTimeout) {
transaction.addError(
step.definition.action!,
step.isCompensating()
? TransactionHandlerType.COMPENSATE
: TransactionHandlerType.INVOKE,
error
)
}
if (!step.isCompensating()) {
if (step.definition.continueOnPermanentFailure) {
if (
step.definition.continueOnPermanentFailure &&
!TransactionTimeoutError.isTransactionTimeoutError(timeoutError!)
) {
for (const childStep of step.next) {
const child = flow.steps[childStep]
child.changeState(TransactionState.SKIPPED)
child.changeState(TransactionStepState.SKIPPED)
}
} else {
flow.state = TransactionState.WAITING_TO_COMPENSATE
@@ -397,7 +526,7 @@ export class TransactionOrchestrator extends EventEmitter {
}
}
if (step.definition.async || flow.options?.strictCheckpoints) {
if (step.definition.async || flow.options?.storeExecution) {
await transaction.saveCheckpoint()
}
@@ -413,33 +542,6 @@ export class TransactionOrchestrator extends EventEmitter {
transaction.emit(eventName, { step, transaction })
}
private async checkTransactionTimeout(transaction, currentSteps) {
let hasTimedOut = false
const flow = transaction.getFlow()
if (
transaction.hasTimeout() &&
!flow.timedOutAt &&
flow.startedAt! + transaction.getTimeoutInterval()! * 1e3 < Date.now()
) {
flow.timedOutAt = Date.now()
this.emit(DistributedTransactionEvent.TIMEOUT, { transaction })
for (const step of currentSteps) {
await TransactionOrchestrator.setStepFailure(
transaction,
step,
new TransactionTimeoutError(),
0
)
}
await transaction.saveCheckpoint()
hasTimedOut = true
}
return hasTimedOut
}
private async executeNext(
transaction: DistributedTransaction
): Promise<void> {
@@ -456,22 +558,19 @@ export class TransactionOrchestrator extends EventEmitter {
const hasTimedOut = await this.checkTransactionTimeout(
transaction,
nextSteps.next
nextSteps.current
)
if (hasTimedOut) {
continue
}
if (nextSteps.remaining === 0) {
if (transaction.hasTimeout()) {
await transaction.clearTransactionTimeout()
void transaction.clearTransactionTimeout()
}
if (flow.options?.retentionTime == undefined) {
await transaction.deleteCheckpoint()
} else {
await transaction.saveCheckpoint()
}
await transaction.saveCheckpoint()
this.emit(DistributedTransactionEvent.FINISH, { transaction })
}
@@ -486,20 +585,20 @@ export class TransactionOrchestrator extends EventEmitter {
step.lastAttempt = Date.now()
step.attempts++
if (curState.state === TransactionState.NOT_STARTED) {
if (curState.state === TransactionStepState.NOT_STARTED) {
if (!step.startedAt) {
step.startedAt = Date.now()
}
if (step.isCompensating()) {
step.changeState(TransactionState.COMPENSATING)
step.changeState(TransactionStepState.COMPENSATING)
if (step.definition.noCompensation) {
step.changeState(TransactionState.REVERTED)
step.changeState(TransactionStepState.REVERTED)
continue
}
} else if (flow.state === TransactionState.INVOKING) {
step.changeState(TransactionState.INVOKING)
step.changeState(TransactionStepState.INVOKING)
}
}
@@ -554,6 +653,14 @@ export class TransactionOrchestrator extends EventEmitter {
transaction
.handler(step.definition.action + "", type, payload, transaction)
.then(async (response: any) => {
if (this.hasExpired({ transaction, step }, Date.now())) {
await this.checkStepTimeout(transaction, step)
await this.checkTransactionTimeout(
transaction,
nextSteps.next.includes(step) ? nextSteps.next : [step]
)
}
await TransactionOrchestrator.setStepSuccess(
transaction,
step,
@@ -561,6 +668,14 @@ export class TransactionOrchestrator extends EventEmitter {
)
})
.catch(async (error) => {
if (this.hasExpired({ transaction, step }, Date.now())) {
await this.checkStepTimeout(transaction, step)
await this.checkTransactionTimeout(
transaction,
nextSteps.next.includes(step) ? nextSteps.next : [step]
)
}
if (
PermanentStepFailureError.isPermanentStepFailureError(error)
) {
@@ -573,7 +688,7 @@ export class TransactionOrchestrator extends EventEmitter {
)
} else {
execution.push(
transaction.saveCheckpoint().then(async () =>
transaction.saveCheckpoint().then(() => {
transaction
.handler(
step.definition.action + "",
@@ -591,12 +706,12 @@ export class TransactionOrchestrator extends EventEmitter {
await setStepFailure(error)
})
)
})
)
}
}
if (hasSyncSteps && flow.options?.strictCheckpoints) {
if (hasSyncSteps && flow.options?.storeExecution) {
await transaction.saveCheckpoint()
}
@@ -630,16 +745,14 @@ export class TransactionOrchestrator extends EventEmitter {
flow.state = TransactionState.INVOKING
flow.startedAt = Date.now()
if (this.options?.storeExecution) {
if (this.options?.store) {
await transaction.saveCheckpoint(
flow.hasAsyncSteps ? 0 : TransactionOrchestrator.DEFAULT_TTL
)
}
if (transaction.hasTimeout()) {
await transaction.scheduleTransactionTimeout(
transaction.getTimeoutInterval()!
)
await transaction.scheduleTransactionTimeout(transaction.getTimeout()!)
}
this.emit(DistributedTransactionEvent.BEGIN, { transaction })
@@ -682,12 +795,19 @@ export class TransactionOrchestrator extends EventEmitter {
this.definition
)
this.options ??= {}
const hasAsyncSteps = features.hasAsyncSteps
const hasStepTimeouts = features.hasStepTimeouts
const hasRetriesTimeout = features.hasRetriesTimeout
const hasTransactionTimeout = !!this.options.timeout
this.options ??= {}
if (hasAsyncSteps || hasStepTimeouts || hasRetriesTimeout) {
if (hasAsyncSteps) {
this.options.store = true
}
if (hasStepTimeouts || hasRetriesTimeout || hasTransactionTimeout) {
this.options.store = true
this.options.storeExecution = true
}
@@ -699,6 +819,7 @@ export class TransactionOrchestrator extends EventEmitter {
hasFailedSteps: false,
hasSkippedSteps: false,
hasWaitingSteps: false,
hasRevertedSteps: false,
timedOutAt: null,
state: TransactionState.NOT_STARTED,
definition: this.definition,
@@ -807,15 +928,16 @@ export class TransactionOrchestrator extends EventEmitter {
new TransactionStep(),
existingSteps?.[id] || {
id,
uuid: definitionCopy.uuid,
depth: level.length - 1,
definition: definitionCopy,
saveResponse: definitionCopy.saveResponse ?? true,
invoke: {
state: TransactionState.NOT_STARTED,
state: TransactionStepState.NOT_STARTED,
status: TransactionStepStatus.IDLE,
},
compensate: {
state: TransactionState.DORMANT,
state: TransactionStepState.DORMANT,
status: TransactionStepStatus.IDLE,
},
attempts: 0,
@@ -861,11 +983,7 @@ export class TransactionOrchestrator extends EventEmitter {
existingTransaction?.context
)
if (
newTransaction &&
this.options?.storeExecution &&
this.options?.strictCheckpoints
) {
if (newTransaction && this.options?.store && this.options?.storeExecution) {
await transaction.saveCheckpoint(
modelFlow.hasAsyncSteps ? 0 : TransactionOrchestrator.DEFAULT_TTL
)
@@ -1,4 +1,4 @@
import { MedusaError } from "@medusajs/utils"
import { MedusaError, TransactionStepState } from "@medusajs/utils"
import {
DistributedTransaction,
TransactionPayload,
@@ -6,8 +6,8 @@ import {
import {
TransactionHandlerType,
TransactionState,
TransactionStepsDefinition,
TransactionStepStatus,
TransactionStepsDefinition,
} from "./types"
export type TransactionStepHandler = (
@@ -38,14 +38,15 @@ export class TransactionStep {
*/
private stepFailed = false
id: string
uuid?: string
depth: number
definition: TransactionStepsDefinition
invoke: {
state: TransactionState
state: TransactionStepState
status: TransactionStepStatus
}
compensate: {
state: TransactionState
state: TransactionStepState
status: TransactionStepStatus
}
attempts: number
@@ -81,24 +82,25 @@ export class TransactionStep {
return !this.stepFailed
}
public changeState(toState: TransactionState) {
public changeState(toState: TransactionStepState) {
const allowed = {
[TransactionState.DORMANT]: [TransactionState.NOT_STARTED],
[TransactionState.NOT_STARTED]: [
TransactionState.INVOKING,
TransactionState.COMPENSATING,
TransactionState.FAILED,
TransactionState.SKIPPED,
[TransactionStepState.DORMANT]: [TransactionStepState.NOT_STARTED],
[TransactionStepState.NOT_STARTED]: [
TransactionStepState.INVOKING,
TransactionStepState.COMPENSATING,
TransactionStepState.FAILED,
TransactionStepState.SKIPPED,
],
[TransactionState.INVOKING]: [
TransactionState.FAILED,
TransactionState.DONE,
[TransactionStepState.INVOKING]: [
TransactionStepState.FAILED,
TransactionStepState.DONE,
TransactionStepState.TIMEOUT,
],
[TransactionState.COMPENSATING]: [
TransactionState.REVERTED,
TransactionState.FAILED,
[TransactionStepState.COMPENSATING]: [
TransactionStepState.REVERTED,
TransactionStepState.FAILED,
],
[TransactionState.DONE]: [TransactionState.COMPENSATING],
[TransactionStepState.DONE]: [TransactionStepState.COMPENSATING],
}
const curState = this.getStates()
@@ -155,10 +157,10 @@ export class TransactionStep {
}
hasTimeout(): boolean {
return !!this.definition.timeout
return !!this.getTimeout()
}
getTimeoutInterval(): number | undefined {
getTimeout(): number | undefined {
return this.definition.timeout
}
@@ -190,7 +192,7 @@ export class TransactionStep {
const { status, state } = this.getStates()
return (
(!this.isCompensating() &&
state === TransactionState.NOT_STARTED &&
state === TransactionStepState.NOT_STARTED &&
flowState === TransactionState.INVOKING) ||
status === TransactionStepStatus.TEMPORARY_FAILURE
)
@@ -199,7 +201,7 @@ export class TransactionStep {
canCompensate(flowState: TransactionState): boolean {
return (
this.isCompensating() &&
this.getStates().state === TransactionState.NOT_STARTED &&
this.getStates().state === TransactionStepState.NOT_STARTED &&
flowState === TransactionState.COMPENSATING
)
}
+94 -27
View File
@@ -1,51 +1,118 @@
import { DistributedTransaction } from "./distributed-transaction"
import { TransactionStep } from "./transaction-step"
export {
TransactionHandlerType,
TransactionState,
TransactionStepStatus,
} from "@medusajs/utils"
export enum TransactionHandlerType {
INVOKE = "invoke",
COMPENSATE = "compensate",
}
/**
* Defines the structure and behavior of a single step within a transaction workflow.
*/
export type TransactionStepsDefinition = {
/**
* A unique identifier for the transaction step.
* This is set automatically when declaring a workflow with "createWorkflow"
*/
uuid?: string
/**
* Specifies the action to be performed in this step.
* "name" is an alias for action when creating a workflow with "createWorkflow".
*/
action?: string
/**
* Indicates whether the workflow should continue even if there is a permanent failure in this step.
* In case it is set to true, the children steps of this step will not be executed and their status will be marked as TransactionStepState.SKIPPED.
*/
continueOnPermanentFailure?: boolean
/**
* If true, no compensation action will be triggered for this step in case of a failure.
*/
noCompensation?: boolean
/**
* The maximum number of times this step should be retried in case of temporary failures.
* The default is 0 (no retries).
*/
maxRetries?: number
/**
* The interval (in seconds) between retry attempts after a temporary failure.
* The default is to retry immediately.
*/
retryInterval?: number
/**
* The interval (in seconds) to retry a step even if its status is "TransactionStepStatus.WAITING".
*/
retryIntervalAwaiting?: number
/**
* The maximum amount of time (in seconds) to wait for this step to complete.
* This is NOT an execution timeout, the step will always be executed and wait for its response.
* If the response is not received within the timeout set, it will be marked as "TransactionStepStatus.TIMEOUT" and the workflow will be reverted as soon as it receives the response.
*/
timeout?: number
/**
* If true, the step is executed asynchronously. This means that the workflow will not wait for the response of this step.
* Async steps require to have their responses set using "setStepSuccess" or "setStepFailure".
* If combined with a timeout, and any response is not set within that interval, the step will be marked as "TransactionStepStatus.TIMEOUT" and the workflow will be reverted immediately.
*/
async?: boolean
/**
* If true, the compensation function for this step is executed asynchronously. Which means, the response has to be set using "setStepSuccess" or "setStepFailure".
*/
compensateAsync?: boolean
/**
* If true, the workflow will not wait for their sibling steps to complete before moving to the next step.
*/
noWait?: boolean
/**
* If true, the response of this step will be stored.
* Default is true.
*/
saveResponse?: boolean
/**
* Defines the next step(s) to execute after this step. Can be a single step or an array of steps.
*/
next?: TransactionStepsDefinition | TransactionStepsDefinition[]
// TODO: add metadata field for customizations
}
export enum TransactionStepStatus {
IDLE = "idle",
OK = "ok",
WAITING = "waiting_response",
TEMPORARY_FAILURE = "temp_failure",
PERMANENT_FAILURE = "permanent_failure",
}
export enum TransactionState {
NOT_STARTED = "not_started",
INVOKING = "invoking",
WAITING_TO_COMPENSATE = "waiting_to_compensate",
COMPENSATING = "compensating",
DONE = "done",
REVERTED = "reverted",
FAILED = "failed",
DORMANT = "dormant",
SKIPPED = "skipped",
}
/**
* Defines the options for a transaction model, which are applicable to the entire workflow.
*/
export type TransactionModelOptions = {
/**
* The global timeout for the entire transaction workflow (in seconds).
*/
timeout?: number
storeExecution?: boolean
/**
* If true, the state of the transaction will be persisted.
*/
store?: boolean
/**
* TBD
*/
retentionTime?: number
strictCheckpoints?: boolean
/**
* If true, the execution details of each step will be stored.
*/
storeExecution?: boolean
// TODO: add metadata field for customizations
}
export type TransactionModel = {
@@ -81,9 +81,16 @@ export class WorkflowManager {
const finalFlow = flow instanceof OrchestratorBuilder ? flow.build() : flow
if (WorkflowManager.workflows.has(workflowId)) {
function excludeStepUuid(key, value) {
return key === "uuid" ? undefined : value
}
const areStepsEqual = finalFlow
? JSON.stringify(finalFlow) ===
JSON.stringify(WorkflowManager.workflows.get(workflowId)!.flow_)
? JSON.stringify(finalFlow, excludeStepUuid) ===
JSON.stringify(
WorkflowManager.workflows.get(workflowId)!.flow_,
excludeStepUuid
)
: true
if (!areStepsEqual) {
@@ -131,14 +138,19 @@ export class WorkflowManager {
}
const finalFlow = flow instanceof OrchestratorBuilder ? flow.build() : flow
const updatedOptions = { ...workflow.options, ...options }
WorkflowManager.workflows.set(workflowId, {
id: workflowId,
flow_: finalFlow,
orchestrator: new TransactionOrchestrator(workflowId, finalFlow, options),
orchestrator: new TransactionOrchestrator(
workflowId,
finalFlow,
updatedOptions
),
handler: WorkflowManager.buildHandlers(workflow.handlers_),
handlers_: workflow.handlers_,
options: { ...workflow.options, ...options },
options: updatedOptions,
requiredModules,
optionalModules,
})