feat(workflows-*): Allow to re run non idempotent but stored workflow with the same transaction id if considered done (#12362)
This commit is contained in:
1 parent
97dd520c64
commit
80007f3afd
31 files changed
+789
-75
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/workflow-engine-inmemory": patch
|
||||||
|
"@medusajs/workflow-engine-redis": patch
|
||||||
|
"@medusajs/orchestration": patch
|
||||||
|
"@medusajs/workflows-sdk": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(workflows-*): Allow to re run non idempotent but stored workflow with the same transaction id if considered done
|
||||||
@@ -40,7 +40,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@medusajs/types": "2.7.1",
|
"@medusajs/types": "2.7.1",
|
||||||
"@medusajs/utils": "2.7.1"
|
"@medusajs/utils": "2.7.1",
|
||||||
|
"ulid": "^2.3.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@mikro-orm/core": "6.4.3",
|
"@mikro-orm/core": "6.4.3",
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ describe("Transaction Orchestrator", () => {
|
|||||||
await strategy.resume(transaction)
|
await strategy.resume(transaction)
|
||||||
|
|
||||||
expect(transaction.transactionId).toBe("transaction_id_123")
|
expect(transaction.transactionId).toBe("transaction_id_123")
|
||||||
|
expect(transaction.runId).toEqual(expect.any(String))
|
||||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||||
|
|
||||||
expect(mocks.one).toBeCalledWith(
|
expect(mocks.one).toBeCalledWith(
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export interface IDistributedSchedulerStorage {
|
|||||||
export interface IDistributedTransactionStorage {
|
export interface IDistributedTransactionStorage {
|
||||||
get(
|
get(
|
||||||
key: string,
|
key: string,
|
||||||
options?: TransactionOptions
|
options?: TransactionOptions & { isCancelling?: boolean }
|
||||||
): Promise<TransactionCheckpoint | undefined>
|
): Promise<TransactionCheckpoint | undefined>
|
||||||
list(): Promise<TransactionCheckpoint[]>
|
list(): Promise<TransactionCheckpoint[]>
|
||||||
save(
|
save(
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export class TransactionPayload {
|
|||||||
class DistributedTransaction extends EventEmitter {
|
class DistributedTransaction extends EventEmitter {
|
||||||
public modelId: string
|
public modelId: string
|
||||||
public transactionId: string
|
public transactionId: string
|
||||||
|
public runId: string
|
||||||
|
|
||||||
private readonly errors: TransactionStepError[] = []
|
private readonly errors: TransactionStepError[] = []
|
||||||
private readonly context: TransactionContext = new TransactionContext()
|
private readonly context: TransactionContext = new TransactionContext()
|
||||||
@@ -109,7 +110,7 @@ class DistributedTransaction extends EventEmitter {
|
|||||||
|
|
||||||
this.transactionId = flow.transactionId
|
this.transactionId = flow.transactionId
|
||||||
this.modelId = flow.modelId
|
this.modelId = flow.modelId
|
||||||
|
this.runId = flow.runId
|
||||||
if (errors) {
|
if (errors) {
|
||||||
this.errors = errors
|
this.errors = errors
|
||||||
}
|
}
|
||||||
@@ -220,7 +221,8 @@ class DistributedTransaction extends EventEmitter {
|
|||||||
|
|
||||||
public static async loadTransaction(
|
public static async loadTransaction(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
transactionId: string
|
transactionId: string,
|
||||||
|
options?: { isCancelling?: boolean }
|
||||||
): Promise<TransactionCheckpoint | null> {
|
): Promise<TransactionCheckpoint | null> {
|
||||||
const key = TransactionOrchestrator.getKeyName(
|
const key = TransactionOrchestrator.getKeyName(
|
||||||
DistributedTransaction.keyPrefix,
|
DistributedTransaction.keyPrefix,
|
||||||
@@ -228,12 +230,13 @@ class DistributedTransaction extends EventEmitter {
|
|||||||
transactionId
|
transactionId
|
||||||
)
|
)
|
||||||
|
|
||||||
const options = TransactionOrchestrator.getWorkflowOptions(modelId)
|
const workflowOptions = TransactionOrchestrator.getWorkflowOptions(modelId)
|
||||||
|
|
||||||
|
const loadedData = await DistributedTransaction.keyValueStore.get(key, {
|
||||||
|
...workflowOptions,
|
||||||
|
isCancelling: options?.isCancelling,
|
||||||
|
})
|
||||||
|
|
||||||
const loadedData = await DistributedTransaction.keyValueStore.get(
|
|
||||||
key,
|
|
||||||
options
|
|
||||||
)
|
|
||||||
if (loadedData) {
|
if (loadedData) {
|
||||||
return loadedData
|
return loadedData
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ulid } from "ulid"
|
||||||
import {
|
import {
|
||||||
DistributedTransaction,
|
DistributedTransaction,
|
||||||
DistributedTransactionType,
|
DistributedTransactionType,
|
||||||
@@ -786,10 +787,13 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
|
|
||||||
const execution: Promise<void | unknown>[] = []
|
const execution: Promise<void | unknown>[] = []
|
||||||
for (const step of nextSteps.next) {
|
for (const step of nextSteps.next) {
|
||||||
const { stopStepExecution } = this.prepareStepForExecution(step, flow)
|
const { shouldContinueExecution } = this.prepareStepForExecution(
|
||||||
|
step,
|
||||||
|
flow
|
||||||
|
)
|
||||||
|
|
||||||
// Should stop the execution if next step cant be handled
|
// Should stop the execution if next step cant be handled
|
||||||
if (!stopStepExecution) {
|
if (!shouldContinueExecution) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -867,7 +871,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
private prepareStepForExecution(
|
private prepareStepForExecution(
|
||||||
step: TransactionStep,
|
step: TransactionStep,
|
||||||
flow: TransactionFlow
|
flow: TransactionFlow
|
||||||
): { stopStepExecution: boolean } {
|
): { shouldContinueExecution: boolean } {
|
||||||
const curState = step.getStates()
|
const curState = step.getStates()
|
||||||
|
|
||||||
step.lastAttempt = Date.now()
|
step.lastAttempt = Date.now()
|
||||||
@@ -883,7 +887,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
|
|
||||||
if (step.definition.noCompensation) {
|
if (step.definition.noCompensation) {
|
||||||
step.changeState(TransactionStepState.REVERTED)
|
step.changeState(TransactionStepState.REVERTED)
|
||||||
return { stopStepExecution: false }
|
return { shouldContinueExecution: false }
|
||||||
}
|
}
|
||||||
} else if (flow.state === TransactionState.INVOKING) {
|
} else if (flow.state === TransactionState.INVOKING) {
|
||||||
step.changeState(TransactionStepState.INVOKING)
|
step.changeState(TransactionStepState.INVOKING)
|
||||||
@@ -892,7 +896,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
|
|
||||||
step.changeStatus(TransactionStepStatus.WAITING)
|
step.changeStatus(TransactionStepStatus.WAITING)
|
||||||
|
|
||||||
return { stopStepExecution: true }
|
return { shouldContinueExecution: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1239,6 +1243,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
flow.state = TransactionState.WAITING_TO_COMPENSATE
|
flow.state = TransactionState.WAITING_TO_COMPENSATE
|
||||||
|
flow.cancelledAt = Date.now()
|
||||||
|
|
||||||
await this.executeNext(transaction)
|
await this.executeNext(transaction)
|
||||||
}
|
}
|
||||||
@@ -1264,7 +1269,8 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
hasStepTimeouts ||
|
hasStepTimeouts ||
|
||||||
hasRetriesTimeout ||
|
hasRetriesTimeout ||
|
||||||
hasTransactionTimeout ||
|
hasTransactionTimeout ||
|
||||||
isIdempotent
|
isIdempotent ||
|
||||||
|
this.options.retentionTime
|
||||||
) {
|
) {
|
||||||
this.options.store = true
|
this.options.store = true
|
||||||
}
|
}
|
||||||
@@ -1292,6 +1298,7 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
modelId: this.id,
|
modelId: this.id,
|
||||||
options: this.options,
|
options: this.options,
|
||||||
transactionId: transactionId,
|
transactionId: transactionId,
|
||||||
|
runId: ulid(),
|
||||||
metadata: flowMetadata,
|
metadata: flowMetadata,
|
||||||
hasAsyncSteps: features.hasAsyncSteps,
|
hasAsyncSteps: features.hasAsyncSteps,
|
||||||
hasFailedSteps: false,
|
hasFailedSteps: false,
|
||||||
@@ -1310,11 +1317,13 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
|
|
||||||
private static async loadTransactionById(
|
private static async loadTransactionById(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
transactionId: string
|
transactionId: string,
|
||||||
|
options?: { isCancelling?: boolean }
|
||||||
): Promise<TransactionCheckpoint | null> {
|
): Promise<TransactionCheckpoint | null> {
|
||||||
const transaction = await DistributedTransaction.loadTransaction(
|
const transaction = await DistributedTransaction.loadTransaction(
|
||||||
modelId,
|
modelId,
|
||||||
transactionId
|
transactionId,
|
||||||
|
options
|
||||||
)
|
)
|
||||||
|
|
||||||
if (transaction !== null) {
|
if (transaction !== null) {
|
||||||
@@ -1487,10 +1496,15 @@ export class TransactionOrchestrator extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
public async retrieveExistingTransaction(
|
public async retrieveExistingTransaction(
|
||||||
transactionId: string,
|
transactionId: string,
|
||||||
handler: TransactionStepHandler
|
handler: TransactionStepHandler,
|
||||||
|
options?: { isCancelling?: boolean }
|
||||||
): Promise<DistributedTransactionType> {
|
): Promise<DistributedTransactionType> {
|
||||||
const existingTransaction =
|
const existingTransaction =
|
||||||
await TransactionOrchestrator.loadTransactionById(this.id, transactionId)
|
await TransactionOrchestrator.loadTransactionById(
|
||||||
|
this.id,
|
||||||
|
transactionId,
|
||||||
|
{ isCancelling: options?.isCancelling }
|
||||||
|
)
|
||||||
|
|
||||||
if (!existingTransaction) {
|
if (!existingTransaction) {
|
||||||
throw new MedusaError(
|
throw new MedusaError(
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ export type TransactionFlow = {
|
|||||||
options?: TransactionModelOptions
|
options?: TransactionModelOptions
|
||||||
definition: TransactionStepsDefinition
|
definition: TransactionStepsDefinition
|
||||||
transactionId: string
|
transactionId: string
|
||||||
|
runId: string
|
||||||
metadata?: {
|
metadata?: {
|
||||||
eventGroupId?: string
|
eventGroupId?: string
|
||||||
parentIdempotencyKey?: string
|
parentIdempotencyKey?: string
|
||||||
@@ -277,6 +278,7 @@ export type TransactionFlow = {
|
|||||||
hasRevertedSteps: boolean
|
hasRevertedSteps: boolean
|
||||||
timedOutAt: number | null
|
timedOutAt: number | null
|
||||||
startedAt?: number
|
startedAt?: number
|
||||||
|
cancelledAt?: number
|
||||||
state: TransactionState
|
state: TransactionState
|
||||||
steps: {
|
steps: {
|
||||||
[key: string]: TransactionStep
|
[key: string]: TransactionStep
|
||||||
|
|||||||
@@ -386,7 +386,8 @@ export class LocalWorkflow {
|
|||||||
|
|
||||||
const transaction = await orchestrator.retrieveExistingTransaction(
|
const transaction = await orchestrator.retrieveExistingTransaction(
|
||||||
uniqueTransactionId,
|
uniqueTransactionId,
|
||||||
handler(this.container_, context)
|
handler(this.container_, context),
|
||||||
|
{ isCancelling: context?.isCancelling }
|
||||||
)
|
)
|
||||||
|
|
||||||
return transaction
|
return transaction
|
||||||
|
|||||||
@@ -78,4 +78,9 @@ export type Context<TManager = unknown> = {
|
|||||||
* preventReleaseEvents
|
* preventReleaseEvents
|
||||||
*/
|
*/
|
||||||
preventReleaseEvents?: boolean
|
preventReleaseEvents?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A boolean value indicating whether the current workflow execution is being cancelled.
|
||||||
|
*/
|
||||||
|
isCancelling?: boolean
|
||||||
}
|
}
|
||||||
@@ -105,6 +105,8 @@ function createContextualWorkflowRunner<
|
|||||||
preventReleaseEvents,
|
preventReleaseEvents,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.isCancelling = isCancel
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
transactionOrIdOrIdempotencyKey,
|
transactionOrIdOrIdempotencyKey,
|
||||||
input,
|
input,
|
||||||
@@ -188,7 +190,7 @@ function createContextualWorkflowRunner<
|
|||||||
__type: MedusaContextType as Context["__type"],
|
__type: MedusaContextType as Context["__type"],
|
||||||
}
|
}
|
||||||
|
|
||||||
context.transactionId ??= ulid()
|
context.transactionId ??= "auto-" + ulid()
|
||||||
context.eventGroupId ??= ulid()
|
context.eventGroupId ??= ulid()
|
||||||
|
|
||||||
return await originalExecution(
|
return await originalExecution(
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export * from "./workflow_idempotent"
|
|||||||
export * from "./workflow_step_timeout"
|
export * from "./workflow_step_timeout"
|
||||||
export * from "./workflow_sync"
|
export * from "./workflow_sync"
|
||||||
export * from "./workflow_transaction_timeout"
|
export * from "./workflow_transaction_timeout"
|
||||||
|
export * from "./workflow_not_idempotent_with_retention"
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
createStep,
|
||||||
|
createWorkflow,
|
||||||
|
StepResponse,
|
||||||
|
} from "@medusajs/framework/workflows-sdk"
|
||||||
|
|
||||||
|
const step_1 = createStep(
|
||||||
|
"step_1",
|
||||||
|
jest.fn((input) => {
|
||||||
|
input.test = "test"
|
||||||
|
return new StepResponse(input, { compensate: 123 })
|
||||||
|
}),
|
||||||
|
jest.fn((compensateInput) => {
|
||||||
|
if (!compensateInput) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StepResponse({
|
||||||
|
reverted: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export const workflowNotIdempotentWithRetentionStep2Invoke = jest.fn(
|
||||||
|
(input, context) => {
|
||||||
|
if (input) {
|
||||||
|
return new StepResponse({ notAsyncResponse: input.hey })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const step_2 = createStep(
|
||||||
|
"step_2",
|
||||||
|
workflowNotIdempotentWithRetentionStep2Invoke,
|
||||||
|
jest.fn((_, context) => {
|
||||||
|
return new StepResponse({
|
||||||
|
step: context.metadata.action,
|
||||||
|
idempotency_key: context.metadata.idempotency_key,
|
||||||
|
reverted: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export const workflowNotIdempotentWithRetentionStep3Invoke = jest.fn((res) => {
|
||||||
|
return new StepResponse({
|
||||||
|
done: {
|
||||||
|
inputFromSyncStep: res.notAsyncResponse,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const step_3 = createStep(
|
||||||
|
"step_3",
|
||||||
|
workflowNotIdempotentWithRetentionStep3Invoke
|
||||||
|
)
|
||||||
|
|
||||||
|
createWorkflow(
|
||||||
|
{
|
||||||
|
name: "workflow_not_idempotent_with_retention",
|
||||||
|
retentionTime: 60,
|
||||||
|
},
|
||||||
|
function (input) {
|
||||||
|
step_1(input)
|
||||||
|
|
||||||
|
step_2({ hey: "oh" })
|
||||||
|
|
||||||
|
const ret2 = step_2({ hey: "hello" }).config({
|
||||||
|
name: "new_step_name",
|
||||||
|
})
|
||||||
|
|
||||||
|
return step_3(ret2)
|
||||||
|
}
|
||||||
|
)
|
||||||
+152
-4
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
DistributedTransactionType,
|
DistributedTransactionType,
|
||||||
|
TransactionState,
|
||||||
WorkflowManager,
|
WorkflowManager,
|
||||||
} from "@medusajs/framework/orchestration"
|
} from "@medusajs/framework/orchestration"
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Module,
|
Module,
|
||||||
Modules,
|
Modules,
|
||||||
|
promiseAll,
|
||||||
TransactionHandlerType,
|
TransactionHandlerType,
|
||||||
} from "@medusajs/framework/utils"
|
} from "@medusajs/framework/utils"
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
|
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
|
||||||
import { WorkflowsModuleService } from "@services"
|
import { WorkflowsModuleService } from "@services"
|
||||||
import { asFunction } from "awilix"
|
import { asFunction } from "awilix"
|
||||||
|
import { ulid } from "ulid"
|
||||||
import { setTimeout as setTimeoutSync } from "timers"
|
import { setTimeout as setTimeoutSync } from "timers"
|
||||||
import { setTimeout as setTimeoutPromise } from "timers/promises"
|
import { setTimeout as setTimeoutPromise } from "timers/promises"
|
||||||
import "../__fixtures__"
|
import "../__fixtures__"
|
||||||
@@ -29,6 +32,8 @@ import {
|
|||||||
conditionalStep3Invoke,
|
conditionalStep3Invoke,
|
||||||
workflow2Step2Invoke,
|
workflow2Step2Invoke,
|
||||||
workflow2Step3Invoke,
|
workflow2Step3Invoke,
|
||||||
|
workflowNotIdempotentWithRetentionStep2Invoke,
|
||||||
|
workflowNotIdempotentWithRetentionStep3Invoke,
|
||||||
} from "../__fixtures__"
|
} from "../__fixtures__"
|
||||||
import {
|
import {
|
||||||
eventGroupWorkflowId,
|
eventGroupWorkflowId,
|
||||||
@@ -43,7 +48,7 @@ const failTrap = (done) => {
|
|||||||
setTimeoutSync(() => {
|
setTimeoutSync(() => {
|
||||||
// REF:https://stackoverflow.com/questions/78028715/jest-async-test-with-event-emitter-isnt-ending
|
// REF:https://stackoverflow.com/questions/78028715/jest-async-test-with-event-emitter-isnt-ending
|
||||||
console.warn(
|
console.warn(
|
||||||
"Jest is breaking the event emit with its debouncer. This allows to continue the test by managing the timeout of the test manually."
|
`Jest is breaking the event emit with its debouncer. This allows to continue the test by managing the timeout of the test manually.`
|
||||||
)
|
)
|
||||||
done()
|
done()
|
||||||
}, 5000)
|
}, 5000)
|
||||||
@@ -94,10 +99,55 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
|
|||||||
serviceName: "workflows",
|
serviceName: "workflows",
|
||||||
field: "workflowExecution",
|
field: "workflowExecution",
|
||||||
},
|
},
|
||||||
|
run_id: {
|
||||||
|
linkable: "workflow_execution_run_id",
|
||||||
|
entity: "WorkflowExecution",
|
||||||
|
primaryKey: "run_id",
|
||||||
|
serviceName: "workflows",
|
||||||
|
field: "workflowExecution",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should prevent executing twice the same workflow in perfect concurrency with the same transactionId and non idempotent and not async but retention time is set", async () => {
|
||||||
|
const transactionId = "transaction_id"
|
||||||
|
const workflowId = "workflow_id" + ulid()
|
||||||
|
|
||||||
|
const step1 = createStep("step1", async () => {
|
||||||
|
await setTimeoutPromise(100)
|
||||||
|
return new StepResponse("step1")
|
||||||
|
})
|
||||||
|
|
||||||
|
createWorkflow(
|
||||||
|
{
|
||||||
|
name: workflowId,
|
||||||
|
retentionTime: 1000,
|
||||||
|
},
|
||||||
|
function () {
|
||||||
|
return new WorkflowResponse(step1())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const [result1, result2] = await promiseAll([
|
||||||
|
workflowOrcModule.run(workflowId, {
|
||||||
|
input: {},
|
||||||
|
transactionId,
|
||||||
|
}),
|
||||||
|
workflowOrcModule
|
||||||
|
.run(workflowId, {
|
||||||
|
input: {},
|
||||||
|
transactionId,
|
||||||
|
})
|
||||||
|
.catch((e) => e),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result1.result).toEqual("step1")
|
||||||
|
expect(result2.message).toEqual(
|
||||||
|
"Transaction already started for transactionId: " + transactionId
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it("should execute an async workflow keeping track of the event group id provided in the context", async () => {
|
it("should execute an async workflow keeping track of the event group id provided in the context", async () => {
|
||||||
const eventGroupId = "event-group-id"
|
const eventGroupId = "event-group-id"
|
||||||
|
|
||||||
@@ -232,6 +282,7 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
|
|||||||
{ obj: "return from 2" },
|
{ obj: "return from 2" },
|
||||||
{ obj: "return from 3" },
|
{ obj: "return from 3" },
|
||||||
])
|
])
|
||||||
|
done()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -309,13 +360,13 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
|
|||||||
stepResponse: { uhuuuu: "yeaah!" },
|
stepResponse: { uhuuuu: "yeaah!" },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(workflow2Step2Invoke).toBeCalledTimes(2)
|
expect(workflow2Step2Invoke).toHaveBeenCalledTimes(2)
|
||||||
expect(workflow2Step2Invoke.mock.calls[0][0]).toEqual({ hey: "oh" })
|
expect(workflow2Step2Invoke.mock.calls[0][0]).toEqual({ hey: "oh" })
|
||||||
expect(workflow2Step2Invoke.mock.calls[1][0]).toEqual({
|
expect(workflow2Step2Invoke.mock.calls[1][0]).toEqual({
|
||||||
hey: "async hello",
|
hey: "async hello",
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(workflow2Step3Invoke).toBeCalledTimes(1)
|
expect(workflow2Step3Invoke).toHaveBeenCalledTimes(1)
|
||||||
expect(workflow2Step3Invoke.mock.calls[0][0]).toEqual({
|
expect(workflow2Step3Invoke.mock.calls[0][0]).toEqual({
|
||||||
uhuuuu: "yeaah!",
|
uhuuuu: "yeaah!",
|
||||||
})
|
})
|
||||||
@@ -327,6 +378,72 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
|
|||||||
expect(executionsList).toHaveLength(1)
|
expect(executionsList).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should return a list of workflow executions and keep it saved when there is a retentionTime set but allow for executing the same workflow multiple times with different run_id if the workflow is considered done", async () => {
|
||||||
|
const transactionId = "transaction_1"
|
||||||
|
await workflowOrcModule.run(
|
||||||
|
"workflow_not_idempotent_with_retention",
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
value: "123",
|
||||||
|
},
|
||||||
|
transactionId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
let { data: executionsList } = await query.graph({
|
||||||
|
entity: "workflow_executions",
|
||||||
|
fields: ["id", "run_id", "transaction_id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(executionsList).toHaveLength(1)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
workflowNotIdempotentWithRetentionStep2Invoke
|
||||||
|
).toHaveBeenCalledTimes(2)
|
||||||
|
expect(
|
||||||
|
workflowNotIdempotentWithRetentionStep2Invoke.mock.calls[0][0]
|
||||||
|
).toEqual({ hey: "oh" })
|
||||||
|
expect(
|
||||||
|
workflowNotIdempotentWithRetentionStep2Invoke.mock.calls[1][0]
|
||||||
|
).toEqual({
|
||||||
|
hey: "hello",
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
workflowNotIdempotentWithRetentionStep3Invoke
|
||||||
|
).toHaveBeenCalledTimes(1)
|
||||||
|
expect(
|
||||||
|
workflowNotIdempotentWithRetentionStep3Invoke.mock.calls[0][0]
|
||||||
|
).toEqual({
|
||||||
|
notAsyncResponse: "hello",
|
||||||
|
})
|
||||||
|
|
||||||
|
await workflowOrcModule.run(
|
||||||
|
"workflow_not_idempotent_with_retention",
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
value: "123",
|
||||||
|
},
|
||||||
|
transactionId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: executionsList2 } = await query.graph({
|
||||||
|
entity: "workflow_executions",
|
||||||
|
filters: {
|
||||||
|
id: { $nin: executionsList.map((e) => e.id) },
|
||||||
|
},
|
||||||
|
fields: ["id", "run_id", "transaction_id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(executionsList2).toHaveLength(1)
|
||||||
|
expect(executionsList2[0].run_id).not.toEqual(
|
||||||
|
executionsList[0].run_id
|
||||||
|
)
|
||||||
|
expect(executionsList2[0].transaction_id).toEqual(
|
||||||
|
executionsList[0].transaction_id
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it("should revert the entire transaction when a step timeout expires", async () => {
|
it("should revert the entire transaction when a step timeout expires", async () => {
|
||||||
const { transaction } = (await workflowOrcModule.run(
|
const { transaction } = (await workflowOrcModule.run(
|
||||||
"workflow_step_timeout",
|
"workflow_step_timeout",
|
||||||
@@ -402,6 +519,37 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
|
|||||||
expect(transaction.getFlow().state).toEqual("reverted")
|
expect(transaction.getFlow().state).toEqual("reverted")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should cancel and revert a non idempotent completed workflow with rentention time given a specific transaction id", async () => {
|
||||||
|
const workflowId = "workflow_not_idempotent_with_retention"
|
||||||
|
const transactionId = "trx_123"
|
||||||
|
|
||||||
|
await workflowOrcModule.run(workflowId, {
|
||||||
|
input: {
|
||||||
|
value: "123",
|
||||||
|
},
|
||||||
|
transactionId,
|
||||||
|
})
|
||||||
|
|
||||||
|
let executions = await workflowOrcModule.listWorkflowExecutions({
|
||||||
|
transaction_id: transactionId,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(executions.length).toEqual(1)
|
||||||
|
expect(executions[0].state).toEqual(TransactionState.DONE)
|
||||||
|
expect(executions[0].transaction_id).toEqual(transactionId)
|
||||||
|
|
||||||
|
await workflowOrcModule.cancel(workflowId, {
|
||||||
|
transactionId,
|
||||||
|
})
|
||||||
|
|
||||||
|
executions = await workflowOrcModule.listWorkflowExecutions({
|
||||||
|
transaction_id: transactionId,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(executions.length).toEqual(1)
|
||||||
|
expect(executions[0].state).toEqual(TransactionState.REVERTED)
|
||||||
|
})
|
||||||
|
|
||||||
it("should run conditional steps if condition is true", (done) => {
|
it("should run conditional steps if condition is true", (done) => {
|
||||||
void workflowOrcModule.subscribe({
|
void workflowOrcModule.subscribe({
|
||||||
workflowId: "workflow_conditional_step",
|
workflowId: "workflow_conditional_step",
|
||||||
@@ -449,8 +597,8 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
|
|||||||
|
|
||||||
describe("Scheduled workflows", () => {
|
describe("Scheduled workflows", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.useFakeTimers()
|
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
|
jest.useFakeTimers()
|
||||||
|
|
||||||
// Register test-value in the container for all tests
|
// Register test-value in the container for all tests
|
||||||
const sharedContainer =
|
const sharedContainer =
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"resolve:aliases": "tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && tsc-alias -p tsconfig.resolved.json && rimraf tsconfig.resolved.json",
|
"resolve:aliases": "tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && tsc-alias -p tsconfig.resolved.json && rimraf tsconfig.resolved.json",
|
||||||
"build": "rimraf dist && tsc --build && npm run resolve:aliases",
|
"build": "rimraf dist && tsc --build && npm run resolve:aliases",
|
||||||
"test": "jest --passWithNoTests --runInBand --bail --forceExit -- src",
|
"test": "jest --passWithNoTests --runInBand --bail --forceExit -- src",
|
||||||
"test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.ts",
|
"test:integration": "jest --forceExit -- integration-tests/**/__tests__/*.ts",
|
||||||
"migration:initial": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create --initial",
|
"migration:initial": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create --initial",
|
||||||
"migration:create": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create",
|
"migration:create": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create",
|
||||||
"migration:up": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:up",
|
"migration:up": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:up",
|
||||||
|
|||||||
+13
-3
@@ -24,6 +24,15 @@
|
|||||||
"nullable": false,
|
"nullable": false,
|
||||||
"mappedType": "text"
|
"mappedType": "text"
|
||||||
},
|
},
|
||||||
|
"run_id": {
|
||||||
|
"name": "run_id",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -151,13 +160,13 @@
|
|||||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_workflow_execution_transaction_id\" ON \"workflow_execution\" (transaction_id) WHERE deleted_at IS NULL"
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_workflow_execution_transaction_id\" ON \"workflow_execution\" (transaction_id) WHERE deleted_at IS NULL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keyName": "IDX_workflow_execution_workflow_id_transaction_id_unique",
|
"keyName": "IDX_workflow_execution_workflow_id_transaction_id_run_id_unique",
|
||||||
"columnNames": [],
|
"columnNames": [],
|
||||||
"composite": false,
|
"composite": false,
|
||||||
"constraint": false,
|
"constraint": false,
|
||||||
"primary": false,
|
"primary": false,
|
||||||
"unique": false,
|
"unique": false,
|
||||||
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_workflow_execution_workflow_id_transaction_id_unique\" ON \"workflow_execution\" (workflow_id, transaction_id) WHERE deleted_at IS NULL"
|
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_workflow_execution_workflow_id_transaction_id_run_id_unique\" ON \"workflow_execution\" (workflow_id, transaction_id, run_id) WHERE deleted_at IS NULL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keyName": "IDX_workflow_execution_state",
|
"keyName": "IDX_workflow_execution_state",
|
||||||
@@ -172,7 +181,8 @@
|
|||||||
"keyName": "workflow_execution_pkey",
|
"keyName": "workflow_execution_pkey",
|
||||||
"columnNames": [
|
"columnNames": [
|
||||||
"workflow_id",
|
"workflow_id",
|
||||||
"transaction_id"
|
"transaction_id",
|
||||||
|
"run_id"
|
||||||
],
|
],
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"constraint": true,
|
"constraint": true,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Migration } from "@mikro-orm/migrations"
|
||||||
|
import { ulid } from "ulid"
|
||||||
|
|
||||||
|
export class Migration20250505092459 extends Migration {
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(
|
||||||
|
`alter table if exists "workflow_execution" drop constraint if exists "workflow_execution_workflow_id_transaction_id_run_id_unique";`
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
`drop index if exists "IDX_workflow_execution_workflow_id_transaction_id_unique";`
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
`alter table if exists "workflow_execution" drop constraint if exists "PK_workflow_execution_workflow_id_transaction_id";`
|
||||||
|
)
|
||||||
|
|
||||||
|
this.addSql(
|
||||||
|
`alter table if exists "workflow_execution" add column if not exists "run_id" text not null default '${ulid()}';`
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_workflow_execution_workflow_id_transaction_id_run_id_unique" ON "workflow_execution" (workflow_id, transaction_id, run_id) WHERE deleted_at IS NULL;`
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
`alter table if exists "workflow_execution" add constraint "workflow_execution_pkey" primary key ("workflow_id", "transaction_id", "run_id");`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(
|
||||||
|
`drop index if exists "IDX_workflow_execution_workflow_id_transaction_id_run_id_unique";`
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
`alter table if exists "workflow_execution" drop constraint if exists "workflow_execution_pkey";`
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
`alter table if exists "workflow_execution" drop column if exists "run_id";`
|
||||||
|
)
|
||||||
|
|
||||||
|
this.addSql(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_workflow_execution_workflow_id_transaction_id_unique" ON "workflow_execution" (workflow_id, transaction_id) WHERE deleted_at IS NULL;`
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
`alter table if exists "workflow_execution" add constraint "workflow_execution_pkey" primary key ("workflow_id", "transaction_id");`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export const WorkflowExecution = model
|
|||||||
id: model.id({ prefix: "wf_exec" }),
|
id: model.id({ prefix: "wf_exec" }),
|
||||||
workflow_id: model.text().primaryKey(),
|
workflow_id: model.text().primaryKey(),
|
||||||
transaction_id: model.text().primaryKey(),
|
transaction_id: model.text().primaryKey(),
|
||||||
|
run_id: model.text().primaryKey(),
|
||||||
execution: model.json().nullable(),
|
execution: model.json().nullable(),
|
||||||
context: model.json().nullable(),
|
context: model.json().nullable(),
|
||||||
state: model.enum(TransactionState),
|
state: model.enum(TransactionState),
|
||||||
@@ -25,7 +26,7 @@ export const WorkflowExecution = model
|
|||||||
where: "deleted_at IS NULL",
|
where: "deleted_at IS NULL",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
on: ["workflow_id", "transaction_id"],
|
on: ["workflow_id", "transaction_id", "run_id"],
|
||||||
unique: true,
|
unique: true,
|
||||||
where: "deleted_at IS NULL",
|
where: "deleted_at IS NULL",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type WorkflowExecution {
|
|||||||
deleted_at: DateTime
|
deleted_at: DateTime
|
||||||
workflow_id: string
|
workflow_id: string
|
||||||
transaction_id: string
|
transaction_id: string
|
||||||
|
run_id: string
|
||||||
execution: JSON
|
execution: JSON
|
||||||
context: JSON
|
context: JSON
|
||||||
state: TransactionState
|
state: TransactionState
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type WorkflowOrchestratorRunOptions<T> = Omit<
|
|||||||
"container"
|
"container"
|
||||||
> & {
|
> & {
|
||||||
transactionId?: string
|
transactionId?: string
|
||||||
|
runId?: string
|
||||||
container?: ContainerLike
|
container?: ContainerLike
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +141,7 @@ export class WorkflowOrchestratorService {
|
|||||||
let { throwOnError, context } = options ?? {}
|
let { throwOnError, context } = options ?? {}
|
||||||
throwOnError ??= true
|
throwOnError ??= true
|
||||||
context ??= {}
|
context ??= {}
|
||||||
context.transactionId = transactionId ?? ulid()
|
context.transactionId = transactionId ?? "auto-" + ulid()
|
||||||
|
|
||||||
const workflowId = isString(workflowIdOrWorkflow)
|
const workflowId = isString(workflowIdOrWorkflow)
|
||||||
? workflowIdOrWorkflow
|
? workflowIdOrWorkflow
|
||||||
@@ -259,7 +260,10 @@ export class WorkflowOrchestratorService {
|
|||||||
const transaction = await this.getRunningTransaction(
|
const transaction = await this.getRunningTransaction(
|
||||||
workflowId,
|
workflowId,
|
||||||
transactionId,
|
transactionId,
|
||||||
options
|
{
|
||||||
|
...options,
|
||||||
|
isCancelling: true,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!transaction) {
|
if (!transaction) {
|
||||||
|
|||||||
+72
-9
@@ -5,11 +5,17 @@ import {
|
|||||||
SchedulerOptions,
|
SchedulerOptions,
|
||||||
SkipExecutionError,
|
SkipExecutionError,
|
||||||
TransactionCheckpoint,
|
TransactionCheckpoint,
|
||||||
|
TransactionContext,
|
||||||
TransactionFlow,
|
TransactionFlow,
|
||||||
TransactionOptions,
|
TransactionOptions,
|
||||||
TransactionStep,
|
TransactionStep,
|
||||||
|
TransactionStepError,
|
||||||
} from "@medusajs/framework/orchestration"
|
} from "@medusajs/framework/orchestration"
|
||||||
import { Logger, ModulesSdkTypes } from "@medusajs/framework/types"
|
import {
|
||||||
|
InferEntityType,
|
||||||
|
Logger,
|
||||||
|
ModulesSdkTypes,
|
||||||
|
} from "@medusajs/framework/types"
|
||||||
import {
|
import {
|
||||||
MedusaError,
|
MedusaError,
|
||||||
TransactionState,
|
TransactionState,
|
||||||
@@ -19,6 +25,7 @@ import {
|
|||||||
} from "@medusajs/framework/utils"
|
} from "@medusajs/framework/utils"
|
||||||
import { WorkflowOrchestratorService } from "@services"
|
import { WorkflowOrchestratorService } from "@services"
|
||||||
import { type CronExpression, parseExpression } from "cron-parser"
|
import { type CronExpression, parseExpression } from "cron-parser"
|
||||||
|
import { WorkflowExecution } from "../models/workflow-execution"
|
||||||
|
|
||||||
function parseNextExecution(
|
function parseNextExecution(
|
||||||
optionsOrExpression: SchedulerOptions | CronExpression | string | number
|
optionsOrExpression: SchedulerOptions | CronExpression | string | number
|
||||||
@@ -86,6 +93,7 @@ export class InMemoryDistributedTransactionStorage
|
|||||||
{
|
{
|
||||||
workflow_id: data.flow.modelId,
|
workflow_id: data.flow.modelId,
|
||||||
transaction_id: data.flow.transactionId,
|
transaction_id: data.flow.transactionId,
|
||||||
|
run_id: data.flow.runId,
|
||||||
execution: data.flow,
|
execution: data.flow,
|
||||||
context: {
|
context: {
|
||||||
data: data.context,
|
data: data.context,
|
||||||
@@ -102,13 +110,16 @@ export class InMemoryDistributedTransactionStorage
|
|||||||
{
|
{
|
||||||
workflow_id: data.flow.modelId,
|
workflow_id: data.flow.modelId,
|
||||||
transaction_id: data.flow.transactionId,
|
transaction_id: data.flow.transactionId,
|
||||||
|
run_id: data.flow.runId,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(
|
async get(
|
||||||
key: string,
|
key: string,
|
||||||
options?: TransactionOptions
|
options?: TransactionOptions & {
|
||||||
|
isCancelling?: boolean
|
||||||
|
}
|
||||||
): Promise<TransactionCheckpoint | undefined> {
|
): Promise<TransactionCheckpoint | undefined> {
|
||||||
const data = this.storage.get(key)
|
const data = this.storage.get(key)
|
||||||
|
|
||||||
@@ -122,23 +133,53 @@ export class InMemoryDistributedTransactionStorage
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [_, workflowId, transactionId] = key.split(":")
|
const [_, workflowId, transactionId] = key.split(":")
|
||||||
const trx = await this.workflowExecutionService_
|
const trx: InferEntityType<typeof WorkflowExecution> | undefined =
|
||||||
.retrieve(
|
await this.workflowExecutionService_
|
||||||
|
.list(
|
||||||
{
|
{
|
||||||
workflow_id: workflowId,
|
workflow_id: workflowId,
|
||||||
transaction_id: transactionId,
|
transaction_id: transactionId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
select: ["execution", "context"],
|
select: ["execution", "context"],
|
||||||
|
order: {
|
||||||
|
id: "desc",
|
||||||
|
},
|
||||||
|
take: 1,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.then((trx) => trx[0])
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
|
||||||
if (trx) {
|
if (trx) {
|
||||||
|
const execution = trx.execution as TransactionFlow
|
||||||
|
|
||||||
|
if (!idempotent) {
|
||||||
|
const isFailedOrReverted = [
|
||||||
|
TransactionState.REVERTED,
|
||||||
|
TransactionState.FAILED,
|
||||||
|
].includes(execution.state)
|
||||||
|
|
||||||
|
const isDone = execution.state === TransactionState.DONE
|
||||||
|
|
||||||
|
const isCancellingAndFailedOrReverted =
|
||||||
|
options?.isCancelling && isFailedOrReverted
|
||||||
|
|
||||||
|
const isNotCancellingAndDoneOrFailedOrReverted =
|
||||||
|
!options?.isCancelling && (isDone || isFailedOrReverted)
|
||||||
|
|
||||||
|
if (
|
||||||
|
isCancellingAndFailedOrReverted ||
|
||||||
|
isNotCancellingAndDoneOrFailedOrReverted
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
flow: trx.execution,
|
flow: trx.execution as TransactionFlow,
|
||||||
context: trx.context.data,
|
context: trx.context?.data as TransactionContext,
|
||||||
errors: trx.context.errors,
|
errors: trx.context?.errors as TransactionStepError[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +222,20 @@ export class InMemoryDistributedTransactionStorage
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store in memory
|
// Store in memory
|
||||||
|
const isNotStarted = data.flow.state === TransactionState.NOT_STARTED
|
||||||
|
const isManualTransactionId = !data.flow.transactionId.startsWith("auto-")
|
||||||
|
|
||||||
|
if (isNotStarted && isManualTransactionId) {
|
||||||
|
const storedData = this.storage.get(key)
|
||||||
|
if (storedData) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_ARGUMENT,
|
||||||
|
"Transaction already started for transactionId: " +
|
||||||
|
data.flow.transactionId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.storage.set(key, data)
|
this.storage.set(key, data)
|
||||||
|
|
||||||
// Optimize DB operations - only perform when necessary
|
// Optimize DB operations - only perform when necessary
|
||||||
@@ -206,15 +261,23 @@ export class InMemoryDistributedTransactionStorage
|
|||||||
key: string
|
key: string
|
||||||
options?: TransactionOptions
|
options?: TransactionOptions
|
||||||
}) {
|
}) {
|
||||||
const isInitialCheckpoint = data.flow.state === TransactionState.NOT_STARTED
|
const isInitialCheckpoint = [TransactionState.NOT_STARTED].includes(
|
||||||
|
data.flow.state
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In case many execution can succeed simultaneously, we need to ensure that the latest
|
* In case many execution can succeed simultaneously, we need to ensure that the latest
|
||||||
* execution does continue if a previous execution is considered finished
|
* execution does continue if a previous execution is considered finished
|
||||||
*/
|
*/
|
||||||
const currentFlow = data.flow
|
const currentFlow = data.flow
|
||||||
|
|
||||||
|
const getOptions = {
|
||||||
|
...options,
|
||||||
|
isCancelling: !!data.flow.cancelledAt,
|
||||||
|
} as Parameters<typeof this.get>[1]
|
||||||
|
|
||||||
const { flow: latestUpdatedFlow } =
|
const { flow: latestUpdatedFlow } =
|
||||||
(await this.get(key, options)) ??
|
(await this.get(key, getOptions)) ??
|
||||||
({ flow: {} } as { flow: TransactionFlow })
|
({ flow: {} } as { flow: TransactionFlow })
|
||||||
|
|
||||||
if (!isInitialCheckpoint && !isPresent(latestUpdatedFlow)) {
|
if (!isInitialCheckpoint && !isPresent(latestUpdatedFlow)) {
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export * from "./workflow_step_timeout"
|
|||||||
export * from "./workflow_sync"
|
export * from "./workflow_sync"
|
||||||
export * from "./workflow_transaction_timeout"
|
export * from "./workflow_transaction_timeout"
|
||||||
export * from "./workflow_when"
|
export * from "./workflow_when"
|
||||||
|
export * from "./workflow_not_idempotent_with_retention"
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
createStep,
|
||||||
|
createWorkflow,
|
||||||
|
StepResponse,
|
||||||
|
} from "@medusajs/framework/workflows-sdk"
|
||||||
|
|
||||||
|
const step_1 = createStep(
|
||||||
|
"step_1",
|
||||||
|
jest.fn((input) => {
|
||||||
|
input.test = "test"
|
||||||
|
return new StepResponse(input, { compensate: 123 })
|
||||||
|
}),
|
||||||
|
jest.fn((compensateInput) => {
|
||||||
|
if (!compensateInput) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StepResponse({
|
||||||
|
reverted: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export const workflowNotIdempotentWithRetentionStep2Invoke = jest.fn(
|
||||||
|
(input, context) => {
|
||||||
|
if (input) {
|
||||||
|
return new StepResponse({ notAsyncResponse: input.hey })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const step_2 = createStep(
|
||||||
|
"step_2",
|
||||||
|
workflowNotIdempotentWithRetentionStep2Invoke,
|
||||||
|
jest.fn((_, context) => {
|
||||||
|
return new StepResponse({
|
||||||
|
step: context.metadata.action,
|
||||||
|
idempotency_key: context.metadata.idempotency_key,
|
||||||
|
reverted: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export const workflowNotIdempotentWithRetentionStep3Invoke = jest.fn((res) => {
|
||||||
|
return new StepResponse({
|
||||||
|
done: {
|
||||||
|
inputFromSyncStep: res.notAsyncResponse,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const step_3 = createStep(
|
||||||
|
"step_3",
|
||||||
|
workflowNotIdempotentWithRetentionStep3Invoke
|
||||||
|
)
|
||||||
|
|
||||||
|
createWorkflow(
|
||||||
|
{
|
||||||
|
name: "workflow_not_idempotent_with_retention",
|
||||||
|
retentionTime: 60,
|
||||||
|
},
|
||||||
|
function (input) {
|
||||||
|
step_1(input)
|
||||||
|
|
||||||
|
step_2({ hey: "oh" })
|
||||||
|
|
||||||
|
const ret2 = step_2({ hey: "hello" }).config({
|
||||||
|
name: "new_step_name",
|
||||||
|
})
|
||||||
|
|
||||||
|
return step_3(ret2)
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
ContainerRegistrationKeys,
|
ContainerRegistrationKeys,
|
||||||
Module,
|
Module,
|
||||||
Modules,
|
Modules,
|
||||||
|
promiseAll,
|
||||||
TransactionHandlerType,
|
TransactionHandlerType,
|
||||||
TransactionStepState,
|
TransactionStepState,
|
||||||
} from "@medusajs/framework/utils"
|
} from "@medusajs/framework/utils"
|
||||||
@@ -33,14 +34,19 @@ import { WorkflowsModuleService } from "../../src/services"
|
|||||||
import "../__fixtures__"
|
import "../__fixtures__"
|
||||||
import { createScheduled } from "../__fixtures__/workflow_scheduled"
|
import { createScheduled } from "../__fixtures__/workflow_scheduled"
|
||||||
import { TestDatabase } from "../utils"
|
import { TestDatabase } from "../utils"
|
||||||
|
import {
|
||||||
|
workflowNotIdempotentWithRetentionStep2Invoke,
|
||||||
|
workflowNotIdempotentWithRetentionStep3Invoke,
|
||||||
|
} from "../__fixtures__"
|
||||||
|
import { ulid } from "ulid"
|
||||||
|
|
||||||
jest.setTimeout(300000)
|
jest.setTimeout(300000)
|
||||||
|
|
||||||
const failTrap = (done) => {
|
const failTrap = (done, name) => {
|
||||||
setTimeoutSync(() => {
|
setTimeoutSync(() => {
|
||||||
// REF:https://stackoverflow.com/questions/78028715/jest-async-test-with-event-emitter-isnt-ending
|
// REF:https://stackoverflow.com/questions/78028715/jest-async-test-with-event-emitter-isnt-ending
|
||||||
console.warn(
|
console.warn(
|
||||||
"Jest is breaking the event emit with its debouncer. This allows to continue the test by managing the timeout of the test manually."
|
`Jest is breaking the event emit with its debouncer. This allows to continue the test by managing the timeout of the test manually. ${name}`
|
||||||
)
|
)
|
||||||
done()
|
done()
|
||||||
}, 5000)
|
}, 5000)
|
||||||
@@ -132,11 +138,56 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
|
|||||||
primaryKey: "workflow_id",
|
primaryKey: "workflow_id",
|
||||||
serviceName: "workflows",
|
serviceName: "workflows",
|
||||||
},
|
},
|
||||||
|
run_id: {
|
||||||
|
entity: "WorkflowExecution",
|
||||||
|
field: "workflowExecution",
|
||||||
|
linkable: "workflow_execution_run_id",
|
||||||
|
primaryKey: "run_id",
|
||||||
|
serviceName: "workflows",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Testing basic workflow", function () {
|
describe("Testing basic workflow", function () {
|
||||||
|
it("should prevent executing twice the same workflow in perfect concurrency with the same transactionId and non idempotent and not async but retention time is set", async () => {
|
||||||
|
const transactionId = "transaction_id"
|
||||||
|
const workflowId = "workflow_id" + ulid()
|
||||||
|
|
||||||
|
const step1 = createStep("step1", async () => {
|
||||||
|
await setTimeout(100)
|
||||||
|
return new StepResponse("step1")
|
||||||
|
})
|
||||||
|
|
||||||
|
createWorkflow(
|
||||||
|
{
|
||||||
|
name: workflowId,
|
||||||
|
retentionTime: 1000,
|
||||||
|
},
|
||||||
|
function () {
|
||||||
|
return new WorkflowResponse(step1())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const [result1, result2] = await promiseAll([
|
||||||
|
workflowOrcModule.run(workflowId, {
|
||||||
|
input: {},
|
||||||
|
transactionId,
|
||||||
|
}),
|
||||||
|
workflowOrcModule
|
||||||
|
.run(workflowId, {
|
||||||
|
input: {},
|
||||||
|
transactionId,
|
||||||
|
})
|
||||||
|
.catch((e) => e),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result1.result).toEqual("step1")
|
||||||
|
expect(result2.message).toEqual(
|
||||||
|
"Transaction already started for transactionId: " + transactionId
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it("should return a list of workflow executions and remove after completed when there is no retentionTime set", async () => {
|
it("should return a list of workflow executions and remove after completed when there is no retentionTime set", async () => {
|
||||||
await workflowOrcModule.run("workflow_1", {
|
await workflowOrcModule.run("workflow_1", {
|
||||||
input: {
|
input: {
|
||||||
@@ -294,6 +345,72 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
|
|||||||
expect(done).toBe(true)
|
expect(done).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should return a list of workflow executions and keep it saved when there is a retentionTime set but allow for executing the same workflow multiple times with different run_id if the workflow is considered done", async () => {
|
||||||
|
const transactionId = "transaction_1"
|
||||||
|
await workflowOrcModule.run(
|
||||||
|
"workflow_not_idempotent_with_retention",
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
value: "123",
|
||||||
|
},
|
||||||
|
transactionId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
let { data: executionsList } = await query.graph({
|
||||||
|
entity: "workflow_executions",
|
||||||
|
fields: ["id", "run_id", "transaction_id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(executionsList).toHaveLength(1)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
workflowNotIdempotentWithRetentionStep2Invoke
|
||||||
|
).toHaveBeenCalledTimes(2)
|
||||||
|
expect(
|
||||||
|
workflowNotIdempotentWithRetentionStep2Invoke.mock.calls[0][0]
|
||||||
|
).toEqual({ hey: "oh" })
|
||||||
|
expect(
|
||||||
|
workflowNotIdempotentWithRetentionStep2Invoke.mock.calls[1][0]
|
||||||
|
).toEqual({
|
||||||
|
hey: "hello",
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
workflowNotIdempotentWithRetentionStep3Invoke
|
||||||
|
).toHaveBeenCalledTimes(1)
|
||||||
|
expect(
|
||||||
|
workflowNotIdempotentWithRetentionStep3Invoke.mock.calls[0][0]
|
||||||
|
).toEqual({
|
||||||
|
notAsyncResponse: "hello",
|
||||||
|
})
|
||||||
|
|
||||||
|
await workflowOrcModule.run(
|
||||||
|
"workflow_not_idempotent_with_retention",
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
value: "123",
|
||||||
|
},
|
||||||
|
transactionId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: executionsList2 } = await query.graph({
|
||||||
|
entity: "workflow_executions",
|
||||||
|
filters: {
|
||||||
|
id: { $nin: executionsList.map((e) => e.id) },
|
||||||
|
},
|
||||||
|
fields: ["id", "run_id", "transaction_id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(executionsList2).toHaveLength(1)
|
||||||
|
expect(executionsList2[0].run_id).not.toEqual(
|
||||||
|
executionsList[0].run_id
|
||||||
|
)
|
||||||
|
expect(executionsList2[0].transaction_id).toEqual(
|
||||||
|
executionsList[0].transaction_id
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it("should revert the entire transaction when a step timeout expires", async () => {
|
it("should revert the entire transaction when a step timeout expires", async () => {
|
||||||
const { transaction, result, errors } = (await workflowOrcModule.run(
|
const { transaction, result, errors } = (await workflowOrcModule.run(
|
||||||
"workflow_step_timeout",
|
"workflow_step_timeout",
|
||||||
@@ -386,7 +503,7 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
|
|||||||
throwOnError: false,
|
throwOnError: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
await setTimeout(200)
|
await setTimeout(500)
|
||||||
|
|
||||||
const { transaction, result, errors } = (await workflowOrcModule.run(
|
const { transaction, result, errors } = (await workflowOrcModule.run(
|
||||||
"workflow_transaction_timeout_async",
|
"workflow_transaction_timeout_async",
|
||||||
@@ -437,7 +554,7 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
failTrap(done)
|
failTrap(done, "workflow_async_background")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should subscribe to a async workflow and receive the response when it finishes", (done) => {
|
it("should subscribe to a async workflow and receive the response when it finishes", (done) => {
|
||||||
@@ -466,7 +583,7 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
|
|||||||
|
|
||||||
expect(onFinish).toHaveBeenCalledTimes(0)
|
expect(onFinish).toHaveBeenCalledTimes(0)
|
||||||
|
|
||||||
failTrap(done)
|
failTrap(done, "workflow_async_background")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should not skip step if condition is true", function (done) {
|
it("should not skip step if condition is true", function (done) {
|
||||||
@@ -488,7 +605,7 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
failTrap(done)
|
failTrap(done, "wf-when")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should cancel an async sub workflow when compensating", (done) => {
|
it("should cancel an async sub workflow when compensating", (done) => {
|
||||||
@@ -526,7 +643,7 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
failTrap(done)
|
failTrap(done, "workflow_async_background_fail")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should cancel and revert a completed workflow", async () => {
|
it("should cancel and revert a completed workflow", async () => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
|
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
|
||||||
import { setTimeout as setTimeoutSync } from "timers"
|
import { setTimeout as setTimeoutSync } from "timers"
|
||||||
import { setTimeout } from "timers/promises"
|
import { setTimeout } from "timers/promises"
|
||||||
|
import { ulid } from "ulid"
|
||||||
import "../__fixtures__"
|
import "../__fixtures__"
|
||||||
|
|
||||||
jest.setTimeout(300000)
|
jest.setTimeout(300000)
|
||||||
@@ -38,6 +39,8 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
|
|||||||
describe("Testing race condition of the workflow during retry", () => {
|
describe("Testing race condition of the workflow during retry", () => {
|
||||||
it("should prevent race continuation of the workflow during retryIntervalAwaiting in background execution", (done) => {
|
it("should prevent race continuation of the workflow during retryIntervalAwaiting in background execution", (done) => {
|
||||||
const transactionId = "transaction_id"
|
const transactionId = "transaction_id"
|
||||||
|
const workflowId = "workflow-1" + ulid()
|
||||||
|
const subWorkflowId = "sub-" + workflowId
|
||||||
|
|
||||||
const step0InvokeMock = jest.fn()
|
const step0InvokeMock = jest.fn()
|
||||||
const step1InvokeMock = jest.fn()
|
const step1InvokeMock = jest.fn()
|
||||||
@@ -60,12 +63,12 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
|
|||||||
return new StepResponse({ result: input })
|
return new StepResponse({ result: input })
|
||||||
})
|
})
|
||||||
|
|
||||||
const subWorkflow = createWorkflow("sub-workflow-1", function () {
|
const subWorkflow = createWorkflow(subWorkflowId, function () {
|
||||||
const status = step1()
|
const status = step1()
|
||||||
return new WorkflowResponse(status)
|
return new WorkflowResponse(status)
|
||||||
})
|
})
|
||||||
|
|
||||||
createWorkflow("workflow-1", function () {
|
createWorkflow(workflowId, function () {
|
||||||
const build = step0()
|
const build = step0()
|
||||||
|
|
||||||
const status = subWorkflow.runAsStep({} as any).config({
|
const status = subWorkflow.runAsStep({} as any).config({
|
||||||
@@ -87,21 +90,28 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
void workflowOrcModule.subscribe({
|
void workflowOrcModule.subscribe({
|
||||||
workflowId: "workflow-1",
|
workflowId,
|
||||||
transactionId,
|
transactionId,
|
||||||
subscriber: (event) => {
|
subscriber: async (event) => {
|
||||||
if (event.eventType === "onFinish") {
|
if (event.eventType === "onFinish") {
|
||||||
|
try {
|
||||||
expect(step0InvokeMock).toHaveBeenCalledTimes(1)
|
expect(step0InvokeMock).toHaveBeenCalledTimes(1)
|
||||||
expect(step1InvokeMock.mock.calls.length).toBeGreaterThan(1)
|
expect(step1InvokeMock.mock.calls.length).toBeGreaterThan(1)
|
||||||
expect(step2InvokeMock).toHaveBeenCalledTimes(1)
|
expect(step2InvokeMock).toHaveBeenCalledTimes(1)
|
||||||
expect(transformMock).toHaveBeenCalledTimes(1)
|
expect(transformMock).toHaveBeenCalledTimes(1)
|
||||||
setTimeoutSync(done, 500)
|
|
||||||
|
// Prevent killing the test to early
|
||||||
|
await setTimeout(500)
|
||||||
|
done()
|
||||||
|
} catch (e) {
|
||||||
|
return done(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
workflowOrcModule
|
workflowOrcModule
|
||||||
.run("workflow-1", { transactionId })
|
.run(workflowId, { transactionId })
|
||||||
.then(({ result }) => {
|
.then(({ result }) => {
|
||||||
expect(result).toBe("result from step 0")
|
expect(result).toBe("result from step 0")
|
||||||
})
|
})
|
||||||
@@ -179,14 +189,19 @@ moduleIntegrationTestRunner<IWorkflowEngineService>({
|
|||||||
transactionId,
|
transactionId,
|
||||||
subscriber: (event) => {
|
subscriber: (event) => {
|
||||||
if (event.eventType === "onFinish") {
|
if (event.eventType === "onFinish") {
|
||||||
|
try {
|
||||||
expect(step0InvokeMock).toHaveBeenCalledTimes(1)
|
expect(step0InvokeMock).toHaveBeenCalledTimes(1)
|
||||||
expect(step0CompensateMock).toHaveBeenCalledTimes(2) // TODO: review this.
|
expect(step0CompensateMock).toHaveBeenCalledTimes(1)
|
||||||
expect(step1InvokeMock).toHaveBeenCalledTimes(1)
|
expect(
|
||||||
|
step1InvokeMock.mock.calls.length
|
||||||
|
).toBeGreaterThanOrEqual(2) // Called every 0.1s at least (it can take more than 0.1sdepending on the event loop congestions)
|
||||||
expect(step1CompensateMock).toHaveBeenCalledTimes(1)
|
expect(step1CompensateMock).toHaveBeenCalledTimes(1)
|
||||||
expect(step2InvokeMock).toHaveBeenCalledTimes(0)
|
expect(step2InvokeMock).toHaveBeenCalledTimes(0)
|
||||||
expect(transformMock).toHaveBeenCalledTimes(0)
|
expect(transformMock).toHaveBeenCalledTimes(0)
|
||||||
|
|
||||||
done()
|
done()
|
||||||
|
} catch (e) {
|
||||||
|
return done(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"resolve:aliases": "tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && tsc-alias -p tsconfig.resolved.json && rimraf tsconfig.resolved.json",
|
"resolve:aliases": "tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && tsc-alias -p tsconfig.resolved.json && rimraf tsconfig.resolved.json",
|
||||||
"build": "rimraf dist && tsc --build && npm run resolve:aliases",
|
"build": "rimraf dist && tsc --build && npm run resolve:aliases",
|
||||||
"test": "jest --passWithNoTests --runInBand --bail --forceExit -- src",
|
"test": "jest --passWithNoTests --runInBand --bail --forceExit -- src",
|
||||||
"test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.ts",
|
"test:integration": "jest --forceExit -- integration-tests/**/__tests__/*.ts",
|
||||||
"migration:initial": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create --initial",
|
"migration:initial": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create --initial",
|
||||||
"migration:create": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create",
|
"migration:create": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create",
|
||||||
"migration:up": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:up",
|
"migration:up": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:up",
|
||||||
|
|||||||
+13
-3
@@ -24,6 +24,15 @@
|
|||||||
"nullable": false,
|
"nullable": false,
|
||||||
"mappedType": "text"
|
"mappedType": "text"
|
||||||
},
|
},
|
||||||
|
"run_id": {
|
||||||
|
"name": "run_id",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -151,13 +160,13 @@
|
|||||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_workflow_execution_transaction_id\" ON \"workflow_execution\" (transaction_id) WHERE deleted_at IS NULL"
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_workflow_execution_transaction_id\" ON \"workflow_execution\" (transaction_id) WHERE deleted_at IS NULL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keyName": "IDX_workflow_execution_workflow_id_transaction_id_unique",
|
"keyName": "IDX_workflow_execution_workflow_id_transaction_id_run_id_unique",
|
||||||
"columnNames": [],
|
"columnNames": [],
|
||||||
"composite": false,
|
"composite": false,
|
||||||
"constraint": false,
|
"constraint": false,
|
||||||
"primary": false,
|
"primary": false,
|
||||||
"unique": false,
|
"unique": false,
|
||||||
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_workflow_execution_workflow_id_transaction_id_unique\" ON \"workflow_execution\" (workflow_id, transaction_id) WHERE deleted_at IS NULL"
|
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_workflow_execution_workflow_id_transaction_id_run_id_unique\" ON \"workflow_execution\" (workflow_id, transaction_id, run_id) WHERE deleted_at IS NULL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keyName": "IDX_workflow_execution_state",
|
"keyName": "IDX_workflow_execution_state",
|
||||||
@@ -172,7 +181,8 @@
|
|||||||
"keyName": "workflow_execution_pkey",
|
"keyName": "workflow_execution_pkey",
|
||||||
"columnNames": [
|
"columnNames": [
|
||||||
"workflow_id",
|
"workflow_id",
|
||||||
"transaction_id"
|
"transaction_id",
|
||||||
|
"run_id"
|
||||||
],
|
],
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"constraint": true,
|
"constraint": true,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Migration } from "@mikro-orm/migrations"
|
||||||
|
import { ulid } from "ulid"
|
||||||
|
|
||||||
|
export class Migration20250505101505 extends Migration {
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(
|
||||||
|
`alter table if exists "workflow_execution" drop constraint if exists "workflow_execution_workflow_id_transaction_id_run_id_unique";`
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
`drop index if exists "IDX_workflow_execution_workflow_id_transaction_id_unique";`
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
`alter table if exists "workflow_execution" drop constraint if exists "PK_workflow_execution_workflow_id_transaction_id";`
|
||||||
|
)
|
||||||
|
|
||||||
|
this.addSql(
|
||||||
|
`alter table if exists "workflow_execution" add column if not exists "run_id" text not null default '${ulid()}';`
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_workflow_execution_workflow_id_transaction_id_run_id_unique" ON "workflow_execution" (workflow_id, transaction_id, run_id) WHERE deleted_at IS NULL;`
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
`alter table if exists "workflow_execution" add constraint "workflow_execution_pkey" primary key ("workflow_id", "transaction_id", "run_id");`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(
|
||||||
|
`drop index if exists "IDX_workflow_execution_workflow_id_transaction_id_run_id_unique";`
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
`alter table if exists "workflow_execution" drop constraint if exists "workflow_execution_pkey";`
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
`alter table if exists "workflow_execution" drop column if exists "run_id";`
|
||||||
|
)
|
||||||
|
|
||||||
|
this.addSql(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_workflow_execution_workflow_id_transaction_id_unique" ON "workflow_execution" (workflow_id, transaction_id) WHERE deleted_at IS NULL;`
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
`alter table if exists "workflow_execution" add constraint "workflow_execution_pkey" primary key ("workflow_id", "transaction_id");`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export const WorkflowExecution = model
|
|||||||
id: model.id({ prefix: "wf_exec" }),
|
id: model.id({ prefix: "wf_exec" }),
|
||||||
workflow_id: model.text().primaryKey(),
|
workflow_id: model.text().primaryKey(),
|
||||||
transaction_id: model.text().primaryKey(),
|
transaction_id: model.text().primaryKey(),
|
||||||
|
run_id: model.text().primaryKey(),
|
||||||
execution: model.json().nullable(),
|
execution: model.json().nullable(),
|
||||||
context: model.json().nullable(),
|
context: model.json().nullable(),
|
||||||
state: model.enum(TransactionState),
|
state: model.enum(TransactionState),
|
||||||
@@ -25,7 +26,7 @@ export const WorkflowExecution = model
|
|||||||
where: "deleted_at IS NULL",
|
where: "deleted_at IS NULL",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
on: ["workflow_id", "transaction_id"],
|
on: ["workflow_id", "transaction_id", "run_id"],
|
||||||
unique: true,
|
unique: true,
|
||||||
where: "deleted_at IS NULL",
|
where: "deleted_at IS NULL",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type WorkflowOrchestratorRunOptions<T> = Omit<
|
|||||||
"container"
|
"container"
|
||||||
> & {
|
> & {
|
||||||
transactionId?: string
|
transactionId?: string
|
||||||
|
runId?: string
|
||||||
container?: ContainerLike
|
container?: ContainerLike
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ export type WorkflowOrchestratorCancelOptions = Omit<
|
|||||||
"transaction" | "transactionId" | "container"
|
"transaction" | "transactionId" | "container"
|
||||||
> & {
|
> & {
|
||||||
transactionId: string
|
transactionId: string
|
||||||
|
runId?: string
|
||||||
container?: ContainerLike
|
container?: ContainerLike
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +207,6 @@ export class WorkflowOrchestratorService {
|
|||||||
throwOnError ??= true
|
throwOnError ??= true
|
||||||
context ??= {}
|
context ??= {}
|
||||||
context.transactionId = transactionId ?? ulid()
|
context.transactionId = transactionId ?? ulid()
|
||||||
|
|
||||||
const workflowId = isString(workflowIdOrWorkflow)
|
const workflowId = isString(workflowIdOrWorkflow)
|
||||||
? workflowIdOrWorkflow
|
? workflowIdOrWorkflow
|
||||||
: workflowIdOrWorkflow.getName()
|
: workflowIdOrWorkflow.getName()
|
||||||
@@ -319,7 +320,7 @@ export class WorkflowOrchestratorService {
|
|||||||
const transaction = await this.getRunningTransaction(
|
const transaction = await this.getRunningTransaction(
|
||||||
workflowId,
|
workflowId,
|
||||||
transactionId,
|
transactionId,
|
||||||
options
|
{ ...options, isCancelling: true }
|
||||||
)
|
)
|
||||||
if (!transaction) {
|
if (!transaction) {
|
||||||
if (!throwOnError) {
|
if (!throwOnError) {
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
SchedulerOptions,
|
SchedulerOptions,
|
||||||
SkipExecutionError,
|
SkipExecutionError,
|
||||||
TransactionCheckpoint,
|
TransactionCheckpoint,
|
||||||
|
TransactionContext,
|
||||||
TransactionFlow,
|
TransactionFlow,
|
||||||
TransactionOptions,
|
TransactionOptions,
|
||||||
TransactionStep,
|
TransactionStep,
|
||||||
|
TransactionStepError,
|
||||||
} from "@medusajs/framework/orchestration"
|
} from "@medusajs/framework/orchestration"
|
||||||
import { Logger, ModulesSdkTypes } from "@medusajs/framework/types"
|
import { Logger, ModulesSdkTypes } from "@medusajs/framework/types"
|
||||||
import {
|
import {
|
||||||
@@ -160,6 +162,7 @@ export class RedisDistributedTransactionStorage
|
|||||||
{
|
{
|
||||||
workflow_id: data.flow.modelId,
|
workflow_id: data.flow.modelId,
|
||||||
transaction_id: data.flow.transactionId,
|
transaction_id: data.flow.transactionId,
|
||||||
|
run_id: data.flow.runId,
|
||||||
execution: data.flow,
|
execution: data.flow,
|
||||||
context: {
|
context: {
|
||||||
data: data.context,
|
data: data.context,
|
||||||
@@ -176,6 +179,7 @@ export class RedisDistributedTransactionStorage
|
|||||||
{
|
{
|
||||||
workflow_id: data.flow.modelId,
|
workflow_id: data.flow.modelId,
|
||||||
transaction_id: data.flow.transactionId,
|
transaction_id: data.flow.transactionId,
|
||||||
|
run_id: data.flow.runId,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -223,7 +227,7 @@ export class RedisDistributedTransactionStorage
|
|||||||
|
|
||||||
async get(
|
async get(
|
||||||
key: string,
|
key: string,
|
||||||
options?: TransactionOptions
|
options?: TransactionOptions & { isCancelling?: boolean }
|
||||||
): Promise<TransactionCheckpoint | undefined> {
|
): Promise<TransactionCheckpoint | undefined> {
|
||||||
const data = await this.redisClient.get(key)
|
const data = await this.redisClient.get(key)
|
||||||
|
|
||||||
@@ -240,26 +244,54 @@ export class RedisDistributedTransactionStorage
|
|||||||
|
|
||||||
const [_, workflowId, transactionId] = key.split(":")
|
const [_, workflowId, transactionId] = key.split(":")
|
||||||
const trx = await this.workflowExecutionService_
|
const trx = await this.workflowExecutionService_
|
||||||
.retrieve(
|
.list(
|
||||||
{
|
{
|
||||||
workflow_id: workflowId,
|
workflow_id: workflowId,
|
||||||
transaction_id: transactionId,
|
transaction_id: transactionId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
select: ["execution", "context"],
|
select: ["execution", "context"],
|
||||||
|
order: {
|
||||||
|
id: "desc",
|
||||||
|
},
|
||||||
|
take: 1,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.then((trx) => trx[0])
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
|
||||||
if (trx) {
|
if (trx) {
|
||||||
const checkpointData = {
|
const execution = trx.execution as TransactionFlow
|
||||||
flow: trx.execution,
|
|
||||||
context: trx.context.data,
|
if (!idempotent) {
|
||||||
errors: trx.context.errors,
|
const isFailedOrReverted = [
|
||||||
|
TransactionState.REVERTED,
|
||||||
|
TransactionState.FAILED,
|
||||||
|
].includes(execution.state)
|
||||||
|
|
||||||
|
const isDone = execution.state === TransactionState.DONE
|
||||||
|
|
||||||
|
const isCancellingAndFailedOrReverted =
|
||||||
|
options?.isCancelling && isFailedOrReverted
|
||||||
|
|
||||||
|
const isNotCancellingAndDoneOrFailedOrReverted =
|
||||||
|
!options?.isCancelling && (isDone || isFailedOrReverted)
|
||||||
|
|
||||||
|
if (
|
||||||
|
isCancellingAndFailedOrReverted ||
|
||||||
|
isNotCancellingAndDoneOrFailedOrReverted
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return checkpointData
|
return {
|
||||||
|
flow: trx.execution as TransactionFlow,
|
||||||
|
context: trx.context?.data as TransactionContext,
|
||||||
|
errors: trx.context?.errors as TransactionStepError[],
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,6 +357,11 @@ export class RedisDistributedTransactionStorage
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isNotStarted = data.flow.state === TransactionState.NOT_STARTED
|
||||||
|
const isManualTransactionId = !data.flow.transactionId.startsWith("auto-")
|
||||||
|
// Only set if not exists
|
||||||
|
const shouldSetNX = isNotStarted && isManualTransactionId
|
||||||
|
|
||||||
// Prepare operations to be executed in batch or pipeline
|
// Prepare operations to be executed in batch or pipeline
|
||||||
const stringifiedData = JSON.stringify(data)
|
const stringifiedData = JSON.stringify(data)
|
||||||
const pipeline = this.redisClient.pipeline()
|
const pipeline = this.redisClient.pipeline()
|
||||||
@@ -332,19 +369,45 @@ export class RedisDistributedTransactionStorage
|
|||||||
// Execute Redis operations
|
// Execute Redis operations
|
||||||
if (!hasFinished) {
|
if (!hasFinished) {
|
||||||
if (ttl) {
|
if (ttl) {
|
||||||
|
if (shouldSetNX) {
|
||||||
|
pipeline.set(key, stringifiedData, "EX", ttl, "NX")
|
||||||
|
} else {
|
||||||
pipeline.set(key, stringifiedData, "EX", ttl)
|
pipeline.set(key, stringifiedData, "EX", ttl)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (shouldSetNX) {
|
||||||
|
pipeline.set(key, stringifiedData, "NX")
|
||||||
} else {
|
} else {
|
||||||
pipeline.set(key, stringifiedData)
|
pipeline.set(key, stringifiedData)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
pipeline.unlink(key)
|
pipeline.unlink(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pipelinePromise = pipeline.exec().then((result) => {
|
||||||
|
if (!shouldSetNX) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionResult = result?.pop()
|
||||||
|
const isOk = !!actionResult?.pop()
|
||||||
|
if (!isOk) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_ARGUMENT,
|
||||||
|
"Transaction already started for transactionId: " +
|
||||||
|
data.flow.transactionId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
// Database operations
|
// Database operations
|
||||||
if (hasFinished && !retentionTime && !idempotent) {
|
if (hasFinished && !retentionTime && !idempotent) {
|
||||||
await promiseAll([pipeline.exec(), this.deleteFromDb(data)])
|
await promiseAll([pipelinePromise, this.deleteFromDb(data)])
|
||||||
} else {
|
} else {
|
||||||
await promiseAll([pipeline.exec(), this.saveToDb(data, retentionTime)])
|
await promiseAll([pipelinePromise, this.saveToDb(data, retentionTime)])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,15 +603,23 @@ export class RedisDistributedTransactionStorage
|
|||||||
key: string
|
key: string
|
||||||
options?: TransactionOptions
|
options?: TransactionOptions
|
||||||
}) {
|
}) {
|
||||||
const isInitialCheckpoint = data.flow.state === TransactionState.NOT_STARTED
|
const isInitialCheckpoint = [TransactionState.NOT_STARTED].includes(
|
||||||
|
data.flow.state
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In case many execution can succeed simultaneously, we need to ensure that the latest
|
* In case many execution can succeed simultaneously, we need to ensure that the latest
|
||||||
* execution does continue if a previous execution is considered finished
|
* execution does continue if a previous execution is considered finished
|
||||||
*/
|
*/
|
||||||
const currentFlow = data.flow
|
const currentFlow = data.flow
|
||||||
|
|
||||||
|
const getOptions = {
|
||||||
|
...options,
|
||||||
|
isCancelling: !!data.flow.cancelledAt,
|
||||||
|
} as Parameters<typeof this.get>[1]
|
||||||
|
|
||||||
const { flow: latestUpdatedFlow } =
|
const { flow: latestUpdatedFlow } =
|
||||||
(await this.get(key, options)) ??
|
(await this.get(key, getOptions)) ??
|
||||||
({ flow: {} } as { flow: TransactionFlow })
|
({ flow: {} } as { flow: TransactionFlow })
|
||||||
|
|
||||||
if (!isInitialCheckpoint && !isPresent(latestUpdatedFlow)) {
|
if (!isInitialCheckpoint && !isPresent(latestUpdatedFlow)) {
|
||||||
|
|||||||
Reference in new issue
Block a user