committed by
GitHub
parent
ae33f4825f
commit
f12299deb1
@@ -0,0 +1,435 @@
|
||||
import { OrchestratorBuilder } from "../../transaction/orchestrator-builder"
|
||||
|
||||
describe("OrchestratorBuilder", () => {
|
||||
let builder: OrchestratorBuilder
|
||||
|
||||
beforeEach(() => {
|
||||
builder = new OrchestratorBuilder()
|
||||
})
|
||||
|
||||
it("should load a TransactionStepsDefinition", () => {
|
||||
builder.load({ action: "foo" })
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
})
|
||||
})
|
||||
|
||||
it("should add a new action after the last action set", () => {
|
||||
builder.addAction("foo")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
})
|
||||
|
||||
builder.addAction("bar")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "bar",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should replace an action by another keeping its next steps", () => {
|
||||
builder.addAction("foo").addAction("axe").replaceAction("foo", "bar")
|
||||
expect(builder.build()).toEqual({
|
||||
action: "bar",
|
||||
next: {
|
||||
action: "axe",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should insert a new action before an existing action", () => {
|
||||
builder.addAction("foo").addAction("bar").insertActionBefore("bar", "axe")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "axe",
|
||||
next: {
|
||||
action: "bar",
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should insert a new action after an existing action", () => {
|
||||
builder.addAction("foo").addAction("axe").insertActionAfter("foo", "bar")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "bar",
|
||||
next: {
|
||||
action: "axe",
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should move an existing action and its next steps to another place. the destination will become next steps of the final branch", () => {
|
||||
builder
|
||||
.addAction("foo")
|
||||
.addAction("bar")
|
||||
.addAction("axe")
|
||||
.addAction("zzz")
|
||||
.moveAction("axe", "foo")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "axe",
|
||||
next: {
|
||||
action: "zzz",
|
||||
next: {
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should merge two action to run in parallel", () => {
|
||||
builder
|
||||
.addAction("foo")
|
||||
.addAction("bar")
|
||||
.addAction("axe")
|
||||
.mergeActions("foo", "axe")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
next: [
|
||||
{
|
||||
action: "foo",
|
||||
next: { action: "bar" },
|
||||
},
|
||||
{ action: "axe" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("should merge multiple actions to run in parallel", () => {
|
||||
builder
|
||||
.addAction("foo")
|
||||
.addAction("bar")
|
||||
.addAction("axe")
|
||||
.addAction("step")
|
||||
.mergeActions("bar", "axe", "step")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
next: [
|
||||
{
|
||||
action: "bar",
|
||||
},
|
||||
{
|
||||
action: "axe",
|
||||
},
|
||||
{
|
||||
action: "step",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("should delete an action", () => {
|
||||
builder.addAction("foo").deleteAction("foo")
|
||||
|
||||
expect(builder.build()).toEqual({})
|
||||
})
|
||||
|
||||
it("should delete an action and keep all the next steps of that branch", () => {
|
||||
builder
|
||||
.addAction("foo")
|
||||
.addAction("bar")
|
||||
.addAction("axe")
|
||||
.deleteAction("bar")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "axe",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should delete an action and remove all the next steps of that branch", () => {
|
||||
builder
|
||||
.addAction("foo")
|
||||
.addAction("bar")
|
||||
.addAction("axe")
|
||||
.addAction("step")
|
||||
.pruneAction("bar")
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
})
|
||||
})
|
||||
|
||||
it("should append a new action to the end of a given action's branch", () => {
|
||||
builder
|
||||
.load({
|
||||
action: "foo",
|
||||
next: [
|
||||
{
|
||||
action: "bar",
|
||||
next: {
|
||||
action: "zzz",
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "axe",
|
||||
},
|
||||
],
|
||||
})
|
||||
.appendAction("step", "bar", { saveResponse: true })
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "foo",
|
||||
next: [
|
||||
{
|
||||
action: "bar",
|
||||
next: {
|
||||
action: "zzz",
|
||||
next: {
|
||||
action: "step",
|
||||
saveResponse: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "axe",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
describe("Composing Complex Transactions", () => {
|
||||
const loadedFlow = {
|
||||
next: {
|
||||
action: "createProduct",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachToSalesChannel",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createPrices",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createInventoryItems",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachInventoryItems",
|
||||
noCompensation: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
it("should load a transaction and add two steps", () => {
|
||||
const builder = new OrchestratorBuilder(loadedFlow)
|
||||
builder
|
||||
.addAction("step_1", { saveResponse: true })
|
||||
.addAction("step_2", { saveResponse: true })
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "createProduct",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachToSalesChannel",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createPrices",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createInventoryItems",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachInventoryItems",
|
||||
noCompensation: true,
|
||||
next: {
|
||||
action: "step_1",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "step_2",
|
||||
saveResponse: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should load a transaction, add 2 steps and merge step_1 to run in parallel with createProduct", () => {
|
||||
const builder = new OrchestratorBuilder(loadedFlow)
|
||||
builder
|
||||
.addAction("step_1", { saveResponse: true })
|
||||
.addAction("step_2", { saveResponse: true })
|
||||
.mergeActions("createProduct", "step_1")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
next: [
|
||||
{
|
||||
action: "createProduct",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachToSalesChannel",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createPrices",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createInventoryItems",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachInventoryItems",
|
||||
noCompensation: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "step_1",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "step_2",
|
||||
saveResponse: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("should load a transaction, add 2 steps and move 'step_1' and all its next steps to run before 'createPrices'", () => {
|
||||
const builder = new OrchestratorBuilder(loadedFlow)
|
||||
builder
|
||||
.addAction("step_1", { saveResponse: true })
|
||||
.addAction("step_2", { saveResponse: true })
|
||||
.moveAction("step_1", "createPrices")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "createProduct",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachToSalesChannel",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "step_1",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "step_2",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createPrices",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createInventoryItems",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachInventoryItems",
|
||||
noCompensation: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should load a transaction, add 2 steps and move 'step_1' to run before 'createPrices' and merge next steps", () => {
|
||||
const builder = new OrchestratorBuilder(loadedFlow)
|
||||
builder
|
||||
.addAction("step_1", { saveResponse: true })
|
||||
.addAction("step_2", { saveResponse: true })
|
||||
.moveAndMergeNextAction("step_1", "createPrices")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "createProduct",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachToSalesChannel",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "step_1",
|
||||
saveResponse: true,
|
||||
next: [
|
||||
{
|
||||
action: "step_2",
|
||||
saveResponse: true,
|
||||
},
|
||||
{
|
||||
action: "createPrices",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "createInventoryItems",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "attachInventoryItems",
|
||||
noCompensation: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("Fully compose a complex transaction", () => {
|
||||
const builder = new OrchestratorBuilder()
|
||||
builder
|
||||
.addAction("step_1", { saveResponse: true })
|
||||
.addAction("step_2", { saveResponse: true })
|
||||
.addAction("step_3", { saveResponse: true })
|
||||
|
||||
builder.insertActionBefore("step_3", "step_2.5", {
|
||||
saveResponse: false,
|
||||
noCompensation: true,
|
||||
})
|
||||
|
||||
builder.insertActionAfter("step_1", "step_1.1", { saveResponse: true })
|
||||
|
||||
builder.insertActionAfter("step_3", "step_4", { async: false })
|
||||
|
||||
builder
|
||||
.mergeActions("step_2", "step_2.5", "step_3")
|
||||
.addAction("step_5", { noCompensation: true })
|
||||
|
||||
builder.deleteAction("step_3")
|
||||
|
||||
expect(builder.build()).toEqual({
|
||||
action: "step_1",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "step_1.1",
|
||||
saveResponse: true,
|
||||
next: [
|
||||
{
|
||||
action: "step_2",
|
||||
saveResponse: true,
|
||||
},
|
||||
{
|
||||
action: "step_2.5",
|
||||
saveResponse: false,
|
||||
noCompensation: true,
|
||||
},
|
||||
{
|
||||
action: "step_4",
|
||||
async: false,
|
||||
next: {
|
||||
action: "step_5",
|
||||
noCompensation: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,861 @@
|
||||
import {
|
||||
TransactionHandlerType,
|
||||
TransactionOrchestrator,
|
||||
TransactionPayload,
|
||||
TransactionState,
|
||||
TransactionStepsDefinition,
|
||||
} from "../../transaction"
|
||||
|
||||
describe("Transaction Orchestrator", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("Should follow the flow by calling steps in order with the correct payload", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
two: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.one(payload)
|
||||
},
|
||||
},
|
||||
secondMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.two(payload)
|
||||
},
|
||||
},
|
||||
}
|
||||
return command[actionId][functionHandlerType](payload)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
next: {
|
||||
action: "secondMethod",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler,
|
||||
{
|
||||
prop: 123,
|
||||
}
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(transaction.transactionId).toBe("transaction_id_123")
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
|
||||
expect(mocks.one).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: {
|
||||
model_id: "transaction-name",
|
||||
reply_to_topic: "trans:transaction-name",
|
||||
idempotency_key: "transaction_id_123:firstMethod:invoke",
|
||||
action: "firstMethod",
|
||||
action_type: "invoke",
|
||||
attempt: 1,
|
||||
timestamp: expect.any(Number),
|
||||
},
|
||||
data: { prop: 123 },
|
||||
})
|
||||
)
|
||||
|
||||
expect(mocks.two).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: {
|
||||
model_id: "transaction-name",
|
||||
reply_to_topic: "trans:transaction-name",
|
||||
idempotency_key: "transaction_id_123:secondMethod:invoke",
|
||||
action: "secondMethod",
|
||||
action_type: "invoke",
|
||||
attempt: 1,
|
||||
timestamp: expect.any(Number),
|
||||
},
|
||||
data: { prop: 123 },
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("Should resume steps in parallel if 'next' is an array", async () => {
|
||||
const actionOrder: string[] = []
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
return actionOrder.push(actionId)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: [
|
||||
{
|
||||
action: "one",
|
||||
},
|
||||
{
|
||||
action: "two",
|
||||
next: {
|
||||
action: "four",
|
||||
next: {
|
||||
action: "six",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "three",
|
||||
next: {
|
||||
action: "five",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
expect(actionOrder).toEqual(["one", "two", "three", "four", "five", "six"])
|
||||
})
|
||||
|
||||
it("Should not execute next steps when a step fails", async () => {
|
||||
const actionOrder: string[] = []
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
if (functionHandlerType === TransactionHandlerType.INVOKE) {
|
||||
actionOrder.push(actionId)
|
||||
}
|
||||
|
||||
if (TransactionHandlerType.INVOKE && actionId === "three") {
|
||||
throw new Error()
|
||||
}
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: [
|
||||
{
|
||||
action: "one",
|
||||
},
|
||||
{
|
||||
action: "two",
|
||||
next: {
|
||||
action: "four",
|
||||
next: {
|
||||
action: "six",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "three",
|
||||
maxRetries: 0,
|
||||
next: {
|
||||
action: "five",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
expect(actionOrder).toEqual(["one", "two", "three"])
|
||||
})
|
||||
|
||||
it("Should store invoke's step response by default or if flag 'saveResponse' is set to true and ignore it if set to false", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation((data) => {
|
||||
return { abc: 1234 }
|
||||
}),
|
||||
two: jest.fn().mockImplementation((data) => {
|
||||
return { def: "567" }
|
||||
}),
|
||||
three: jest.fn().mockImplementation((data, context) => {
|
||||
return { end: true, onePropAbc: context.invoke.firstMethod.abc }
|
||||
}),
|
||||
four: jest.fn().mockImplementation((data) => {
|
||||
return null
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: (data) => {
|
||||
return mocks.one(data)
|
||||
},
|
||||
},
|
||||
secondMethod: {
|
||||
[TransactionHandlerType.INVOKE]: (data) => {
|
||||
return mocks.two(data)
|
||||
},
|
||||
},
|
||||
thirdMethod: {
|
||||
[TransactionHandlerType.INVOKE]: (data, context) => {
|
||||
return mocks.three(data, context)
|
||||
},
|
||||
},
|
||||
fourthMethod: {
|
||||
[TransactionHandlerType.INVOKE]: (data) => {
|
||||
return mocks.four(data)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return command[actionId][functionHandlerType](
|
||||
payload.data,
|
||||
payload.context
|
||||
)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
next: {
|
||||
action: "secondMethod",
|
||||
next: {
|
||||
action: "thirdMethod",
|
||||
next: {
|
||||
action: "fourthMethod",
|
||||
saveResponse: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler,
|
||||
{ prop: 123 }
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(mocks.one).toBeCalledWith({ prop: 123 })
|
||||
expect(mocks.two).toBeCalledWith({ prop: 123 })
|
||||
|
||||
expect(mocks.three).toBeCalledWith(
|
||||
{ prop: 123 },
|
||||
{
|
||||
payload: {
|
||||
prop: 123,
|
||||
},
|
||||
invoke: {
|
||||
firstMethod: { abc: 1234 },
|
||||
secondMethod: { def: "567" },
|
||||
thirdMethod: { end: true, onePropAbc: 1234 },
|
||||
},
|
||||
compensate: {},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("Should store compensate's step responses if flag 'saveResponse' is set to true", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation(() => {
|
||||
return 1
|
||||
}),
|
||||
two: jest.fn().mockImplementation(() => {
|
||||
return 2
|
||||
}),
|
||||
compensateOne: jest.fn().mockImplementation((compensateContext) => {
|
||||
return "compensate 1 - 2 = " + compensateContext.secondMethod.two
|
||||
}),
|
||||
compensateTwo: jest.fn().mockImplementation((compensateContext) => {
|
||||
return { two: "isCompensated" }
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
return mocks.one()
|
||||
},
|
||||
[TransactionHandlerType.COMPENSATE]: ({ compensate }) => {
|
||||
return mocks.compensateOne({ ...compensate })
|
||||
},
|
||||
},
|
||||
secondMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
return mocks.two()
|
||||
},
|
||||
[TransactionHandlerType.COMPENSATE]: ({ compensate }) => {
|
||||
return mocks.compensateTwo({ ...compensate })
|
||||
},
|
||||
},
|
||||
thirdMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
throw new Error("failed")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return command[actionId][functionHandlerType](payload.context)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "secondMethod",
|
||||
saveResponse: true,
|
||||
next: {
|
||||
action: "thirdMethod",
|
||||
noCompensation: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
const resposes = transaction.getContext()
|
||||
|
||||
expect(mocks.compensateTwo).toBeCalledWith({})
|
||||
|
||||
expect(mocks.compensateOne).toBeCalledWith({
|
||||
secondMethod: {
|
||||
two: "isCompensated",
|
||||
},
|
||||
})
|
||||
|
||||
expect(resposes.compensate.firstMethod).toEqual(
|
||||
"compensate 1 - 2 = isCompensated"
|
||||
)
|
||||
})
|
||||
|
||||
it("Should continue the exection of next steps without waiting for the execution of all its parents when flag 'noWait' is set to true", async () => {
|
||||
const actionOrder: string[] = []
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
if (functionHandlerType === TransactionHandlerType.INVOKE) {
|
||||
actionOrder.push(actionId)
|
||||
}
|
||||
|
||||
if (
|
||||
functionHandlerType === TransactionHandlerType.INVOKE &&
|
||||
actionId === "three"
|
||||
) {
|
||||
throw new Error()
|
||||
}
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: [
|
||||
{
|
||||
action: "one",
|
||||
next: {
|
||||
action: "five",
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "two",
|
||||
noWait: true,
|
||||
next: {
|
||||
action: "four",
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "three",
|
||||
maxRetries: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler
|
||||
)
|
||||
|
||||
strategy.resume(transaction)
|
||||
|
||||
await new Promise((ok) => {
|
||||
strategy.on("finish", ok)
|
||||
})
|
||||
|
||||
expect(actionOrder).toEqual(["one", "two", "three", "four"])
|
||||
})
|
||||
|
||||
it("Should retry steps X times when a step fails and compensate steps afterward", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
compensateOne: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
two: jest.fn().mockImplementation((payload) => {
|
||||
throw new Error()
|
||||
}),
|
||||
compensateTwo: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.one(payload)
|
||||
},
|
||||
[TransactionHandlerType.COMPENSATE]: () => {
|
||||
mocks.compensateOne(payload)
|
||||
},
|
||||
},
|
||||
secondMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.two(payload)
|
||||
},
|
||||
[TransactionHandlerType.COMPENSATE]: () => {
|
||||
mocks.compensateTwo(payload)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return command[actionId][functionHandlerType](payload)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
maxRetries: 3,
|
||||
next: {
|
||||
action: "secondMethod",
|
||||
maxRetries: 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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.one).toBeCalledTimes(1)
|
||||
expect(mocks.two).toBeCalledTimes(4)
|
||||
expect(transaction.getState()).toBe(TransactionState.REVERTED)
|
||||
expect(mocks.compensateOne).toBeCalledTimes(1)
|
||||
|
||||
expect(mocks.two).nthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
attempt: 1,
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
expect(mocks.two).nthCalledWith(
|
||||
4,
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
attempt: 4,
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("Should fail a transaction if any step fails after retrying X time to compensate it", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation((payload) => {
|
||||
throw new Error()
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.one(payload)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return command[actionId][functionHandlerType](payload)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
maxRetries: 1,
|
||||
},
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(mocks.one).toBeCalledTimes(2)
|
||||
expect(transaction.getState()).toBe(TransactionState.FAILED)
|
||||
})
|
||||
|
||||
it("Should complete a transaction if a failing step has the flag 'continueOnPermanentFailure' set to true", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation((payload) => {
|
||||
return
|
||||
}),
|
||||
two: jest.fn().mockImplementation((payload) => {
|
||||
throw new Error()
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.one(payload)
|
||||
},
|
||||
},
|
||||
secondMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.two(payload)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return command[actionId][functionHandlerType](payload)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
next: {
|
||||
action: "secondMethod",
|
||||
maxRetries: 1,
|
||||
continueOnPermanentFailure: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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.one).toBeCalledTimes(1)
|
||||
expect(mocks.two).toBeCalledTimes(2)
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
expect(transaction.isPartiallyCompleted).toBe(true)
|
||||
})
|
||||
|
||||
it("Should hold the status INVOKING while the transaction hasn't finished", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation((payload) => {
|
||||
return
|
||||
}),
|
||||
two: jest.fn().mockImplementation((payload) => {
|
||||
return
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.one(payload)
|
||||
},
|
||||
},
|
||||
secondMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.two(payload)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return command[actionId][functionHandlerType](payload)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
async: true,
|
||||
next: {
|
||||
action: "secondMethod",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler,
|
||||
{
|
||||
myPayloadProp: "test",
|
||||
}
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(mocks.one).toBeCalledTimes(1)
|
||||
expect(mocks.two).toBeCalledTimes(0)
|
||||
expect(transaction.getState()).toBe(TransactionState.INVOKING)
|
||||
|
||||
const mocktransactionId = TransactionOrchestrator.getKeyName(
|
||||
transaction.transactionId,
|
||||
"firstMethod",
|
||||
TransactionHandlerType.INVOKE
|
||||
)
|
||||
await strategy.registerStepSuccess(
|
||||
mocktransactionId,
|
||||
undefined,
|
||||
transaction
|
||||
)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
})
|
||||
|
||||
it("Should hold the status COMPENSATING while the transaction hasn't finished compensating", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation((payload) => {
|
||||
return
|
||||
}),
|
||||
compensateOne: jest.fn().mockImplementation((payload) => {
|
||||
return
|
||||
}),
|
||||
two: jest.fn().mockImplementation((payload) => {
|
||||
return
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.one(payload)
|
||||
},
|
||||
[TransactionHandlerType.COMPENSATE]: () => {
|
||||
mocks.compensateOne(payload)
|
||||
},
|
||||
},
|
||||
secondMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.two(payload)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return command[actionId][functionHandlerType](payload)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
async: true,
|
||||
next: {
|
||||
action: "secondMethod",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler
|
||||
)
|
||||
|
||||
const mocktransactionId = TransactionOrchestrator.getKeyName(
|
||||
transaction.transactionId,
|
||||
"firstMethod",
|
||||
TransactionHandlerType.INVOKE
|
||||
)
|
||||
|
||||
const registerBeforeAllowed = await strategy
|
||||
.registerStepFailure(mocktransactionId, null, handler)
|
||||
.catch((e) => e.message)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(mocks.one).toBeCalledTimes(1)
|
||||
expect(mocks.compensateOne).toBeCalledTimes(0)
|
||||
expect(mocks.two).toBeCalledTimes(0)
|
||||
expect(registerBeforeAllowed).toEqual(
|
||||
"Cannot set step failure when status is idle"
|
||||
)
|
||||
expect(transaction.getState()).toBe(TransactionState.INVOKING)
|
||||
|
||||
const resumedTransaction = await strategy.registerStepFailure(
|
||||
mocktransactionId,
|
||||
null,
|
||||
handler
|
||||
)
|
||||
|
||||
expect(resumedTransaction.getState()).toBe(TransactionState.COMPENSATING)
|
||||
expect(mocks.compensateOne).toBeCalledTimes(1)
|
||||
|
||||
const mocktransactionIdCompensate = TransactionOrchestrator.getKeyName(
|
||||
transaction.transactionId,
|
||||
"firstMethod",
|
||||
TransactionHandlerType.COMPENSATE
|
||||
)
|
||||
await strategy.registerStepSuccess(
|
||||
mocktransactionIdCompensate,
|
||||
undefined,
|
||||
resumedTransaction
|
||||
)
|
||||
|
||||
expect(resumedTransaction.getState()).toBe(TransactionState.REVERTED)
|
||||
})
|
||||
|
||||
it("Should revert a transaction when .cancelTransaction() is called", async () => {
|
||||
const mocks = {
|
||||
one: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
oneCompensate: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
two: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
twoCompensate: jest.fn().mockImplementation((payload) => {
|
||||
return payload
|
||||
}),
|
||||
}
|
||||
|
||||
async function handler(
|
||||
actionId: string,
|
||||
functionHandlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) {
|
||||
const command = {
|
||||
firstMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.one(payload)
|
||||
},
|
||||
[TransactionHandlerType.COMPENSATE]: () => {
|
||||
mocks.oneCompensate(payload)
|
||||
},
|
||||
},
|
||||
secondMethod: {
|
||||
[TransactionHandlerType.INVOKE]: () => {
|
||||
mocks.two(payload)
|
||||
},
|
||||
[TransactionHandlerType.COMPENSATE]: () => {
|
||||
mocks.twoCompensate(payload)
|
||||
},
|
||||
},
|
||||
}
|
||||
return command[actionId][functionHandlerType](payload)
|
||||
}
|
||||
|
||||
const flow: TransactionStepsDefinition = {
|
||||
next: {
|
||||
action: "firstMethod",
|
||||
next: {
|
||||
action: "secondMethod",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const strategy = new TransactionOrchestrator("transaction-name", flow)
|
||||
|
||||
const transaction = await strategy.beginTransaction(
|
||||
"transaction_id_123",
|
||||
handler
|
||||
)
|
||||
|
||||
await strategy.resume(transaction)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
expect(mocks.one).toBeCalledTimes(1)
|
||||
expect(mocks.two).toBeCalledTimes(1)
|
||||
|
||||
await strategy.cancelTransaction(transaction)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.REVERTED)
|
||||
expect(mocks.one).toBeCalledTimes(1)
|
||||
expect(mocks.two).toBeCalledTimes(1)
|
||||
expect(mocks.oneCompensate).toBeCalledTimes(1)
|
||||
expect(mocks.twoCompensate).toBeCalledTimes(1)
|
||||
})
|
||||
})
|
||||
176
packages/orchestration/src/__tests__/workflow/global-workflow.ts
Normal file
176
packages/orchestration/src/__tests__/workflow/global-workflow.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { GlobalWorkflow } from "../../workflow/global-workflow"
|
||||
import { TransactionState } from "../../transaction/types"
|
||||
import { WorkflowManager } from "../../workflow/workflow-manager"
|
||||
|
||||
describe("WorkflowManager", () => {
|
||||
const container: any = {}
|
||||
|
||||
let handlers
|
||||
let flow: GlobalWorkflow
|
||||
let asyncStepIdempotencyKey: string
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
WorkflowManager.unregisterAll()
|
||||
|
||||
handlers = new Map()
|
||||
handlers.set("foo", {
|
||||
invoke: jest.fn().mockResolvedValue({ done: true }),
|
||||
compensate: jest.fn(() => {}),
|
||||
})
|
||||
|
||||
handlers.set("bar", {
|
||||
invoke: jest.fn().mockResolvedValue({ done: true }),
|
||||
compensate: jest.fn().mockResolvedValue({}),
|
||||
})
|
||||
|
||||
handlers.set("broken", {
|
||||
invoke: jest.fn(() => {
|
||||
throw new Error("Step Failed")
|
||||
}),
|
||||
compensate: jest.fn().mockResolvedValue({ bar: 123, reverted: true }),
|
||||
})
|
||||
|
||||
handlers.set("callExternal", {
|
||||
invoke: jest.fn(({ metadata }) => {
|
||||
asyncStepIdempotencyKey = metadata.idempotency_key
|
||||
}),
|
||||
})
|
||||
|
||||
WorkflowManager.register(
|
||||
"create-product",
|
||||
{
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "bar",
|
||||
},
|
||||
},
|
||||
handlers
|
||||
)
|
||||
|
||||
WorkflowManager.register(
|
||||
"broken-delivery",
|
||||
{
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "broken",
|
||||
},
|
||||
},
|
||||
handlers
|
||||
)
|
||||
|
||||
WorkflowManager.register(
|
||||
"deliver-product",
|
||||
{
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "callExternal",
|
||||
async: true,
|
||||
noCompensation: true,
|
||||
next: {
|
||||
action: "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
handlers
|
||||
)
|
||||
|
||||
flow = new GlobalWorkflow(container)
|
||||
})
|
||||
|
||||
it("should return all registered workflows", () => {
|
||||
const wf = Object.keys(Object.fromEntries(WorkflowManager.getWorkflows()))
|
||||
expect(wf).toEqual(["create-product", "broken-delivery", "deliver-product"])
|
||||
})
|
||||
|
||||
it("should begin a transaction and returns its final state", async () => {
|
||||
const transaction = await flow.run("create-product", "t-id", {
|
||||
input: 123,
|
||||
})
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(0)
|
||||
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
})
|
||||
|
||||
it("should begin a transaction and revert it when fail", async () => {
|
||||
const transaction = await flow.run("broken-delivery", "t-id")
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("broken").invoke).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("broken").compensate).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.REVERTED)
|
||||
})
|
||||
|
||||
it("should continue an asyncronous transaction after reporting a successful step", async () => {
|
||||
const transaction = await flow.run("deliver-product", "t-id")
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("callExternal").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.INVOKING)
|
||||
|
||||
const continuation = await flow.registerStepSuccess(
|
||||
"deliver-product",
|
||||
asyncStepIdempotencyKey,
|
||||
{ ok: true }
|
||||
)
|
||||
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(continuation.getState()).toBe(TransactionState.DONE)
|
||||
})
|
||||
|
||||
it("should revert an asyncronous transaction after reporting a failure step", async () => {
|
||||
const transaction = await flow.run("deliver-product", "t-id")
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("callExternal").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.INVOKING)
|
||||
|
||||
const continuation = await flow.registerStepFailure(
|
||||
"deliver-product",
|
||||
asyncStepIdempotencyKey,
|
||||
{ ok: true }
|
||||
)
|
||||
|
||||
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(0)
|
||||
expect(handlers.get("bar").compensate).toHaveBeenCalledTimes(0)
|
||||
|
||||
// Failed because the async is flagged as noCompensation
|
||||
expect(continuation.getState()).toBe(TransactionState.FAILED)
|
||||
})
|
||||
|
||||
it("should update an existing global flow with a new step and a new handler", async () => {
|
||||
const definition =
|
||||
WorkflowManager.getTransactionDefinition("create-product")
|
||||
|
||||
definition.insertActionBefore("bar", "xor", { maxRetries: 3 })
|
||||
|
||||
const additionalHandlers = new Map()
|
||||
additionalHandlers.set("xor", {
|
||||
invoke: jest.fn().mockResolvedValue({ done: true }),
|
||||
compensate: jest.fn().mockResolvedValue({}),
|
||||
})
|
||||
|
||||
WorkflowManager.update("create-product", definition, additionalHandlers)
|
||||
|
||||
const transaction = await flow.run("create-product", "t-id")
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(additionalHandlers.get("xor").invoke).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
})
|
||||
})
|
||||
175
packages/orchestration/src/__tests__/workflow/local-workflow.ts
Normal file
175
packages/orchestration/src/__tests__/workflow/local-workflow.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { LocalWorkflow } from "../../workflow/local-workflow"
|
||||
import { TransactionState } from "../../transaction/types"
|
||||
import { WorkflowManager } from "../../workflow/workflow-manager"
|
||||
|
||||
describe("WorkflowManager", () => {
|
||||
const container: any = {}
|
||||
|
||||
let handlers
|
||||
let asyncStepIdempotencyKey: string
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
WorkflowManager.unregisterAll()
|
||||
|
||||
handlers = new Map()
|
||||
handlers.set("foo", {
|
||||
invoke: jest.fn().mockResolvedValue({ done: true }),
|
||||
compensate: jest.fn(() => {}),
|
||||
})
|
||||
|
||||
handlers.set("bar", {
|
||||
invoke: jest.fn().mockResolvedValue({ done: true }),
|
||||
compensate: jest.fn().mockResolvedValue({}),
|
||||
})
|
||||
|
||||
handlers.set("broken", {
|
||||
invoke: jest.fn(() => {
|
||||
throw new Error("Step Failed")
|
||||
}),
|
||||
compensate: jest.fn().mockResolvedValue({ bar: 123, reverted: true }),
|
||||
})
|
||||
|
||||
handlers.set("callExternal", {
|
||||
invoke: jest.fn(({ metadata }) => {
|
||||
asyncStepIdempotencyKey = metadata.idempotency_key
|
||||
}),
|
||||
})
|
||||
|
||||
WorkflowManager.register(
|
||||
"create-product",
|
||||
{
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "bar",
|
||||
},
|
||||
},
|
||||
handlers
|
||||
)
|
||||
|
||||
WorkflowManager.register(
|
||||
"broken-delivery",
|
||||
{
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "broken",
|
||||
},
|
||||
},
|
||||
handlers
|
||||
)
|
||||
|
||||
WorkflowManager.register(
|
||||
"deliver-product",
|
||||
{
|
||||
action: "foo",
|
||||
next: {
|
||||
action: "callExternal",
|
||||
async: true,
|
||||
noCompensation: true,
|
||||
next: {
|
||||
action: "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
handlers
|
||||
)
|
||||
})
|
||||
|
||||
it("should return all registered workflows", () => {
|
||||
const wf = Object.keys(Object.fromEntries(WorkflowManager.getWorkflows()))
|
||||
expect(wf).toEqual(["create-product", "broken-delivery", "deliver-product"])
|
||||
})
|
||||
|
||||
it("should begin a transaction and returns its final state", async () => {
|
||||
const flow = new LocalWorkflow("create-product", container)
|
||||
const transaction = await flow.run("t-id", {
|
||||
input: 123,
|
||||
})
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(0)
|
||||
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
})
|
||||
|
||||
it("should begin a transaction and revert it when fail", async () => {
|
||||
const flow = new LocalWorkflow("broken-delivery", container)
|
||||
const transaction = await flow.run("t-id")
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("broken").invoke).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("broken").compensate).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.REVERTED)
|
||||
})
|
||||
|
||||
it("should continue an asyncronous transaction after reporting a successful step", async () => {
|
||||
const flow = new LocalWorkflow("deliver-product", container)
|
||||
const transaction = await flow.run("t-id")
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("callExternal").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.INVOKING)
|
||||
|
||||
const continuation = await flow.registerStepSuccess(
|
||||
asyncStepIdempotencyKey,
|
||||
{ ok: true }
|
||||
)
|
||||
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(continuation.getState()).toBe(TransactionState.DONE)
|
||||
})
|
||||
|
||||
it("should revert an asyncronous transaction after reporting a failure step", async () => {
|
||||
const flow = new LocalWorkflow("deliver-product", container)
|
||||
const transaction = await flow.run("t-id")
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("callExternal").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.INVOKING)
|
||||
|
||||
const continuation = await flow.registerStepFailure(
|
||||
asyncStepIdempotencyKey,
|
||||
{ ok: true }
|
||||
)
|
||||
|
||||
expect(handlers.get("foo").compensate).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(0)
|
||||
expect(handlers.get("bar").compensate).toHaveBeenCalledTimes(0)
|
||||
|
||||
// Failed because the async is flagged as noCompensation
|
||||
expect(continuation.getState()).toBe(TransactionState.FAILED)
|
||||
})
|
||||
|
||||
it("should update a flow with a new step and a new handler", async () => {
|
||||
const flow = new LocalWorkflow("create-product", container)
|
||||
|
||||
const additionalHandler = {
|
||||
invoke: jest.fn().mockResolvedValue({ done: true }),
|
||||
compensate: jest.fn().mockResolvedValue({}),
|
||||
}
|
||||
|
||||
flow.insertActionBefore("bar", "xor", additionalHandler, { maxRetries: 3 })
|
||||
|
||||
const transaction = await flow.run("t-id")
|
||||
|
||||
expect(handlers.get("foo").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.get("bar").invoke).toHaveBeenCalledTimes(1)
|
||||
expect(additionalHandler.invoke).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(transaction.getState()).toBe(TransactionState.DONE)
|
||||
|
||||
expect(
|
||||
WorkflowManager.getWorkflow("create-product")?.handlers_.has("xor")
|
||||
).toEqual(false)
|
||||
})
|
||||
})
|
||||
@@ -1 +1,3 @@
|
||||
export * from "./joiner"
|
||||
export * from "./transaction"
|
||||
export * from "./workflow"
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { TransactionFlow } from "./transaction-orchestrator"
|
||||
import { TransactionHandlerType, TransactionState } from "./types"
|
||||
|
||||
/**
|
||||
* @typedef TransactionMetadata
|
||||
* @property model_id - The id of the model_id that created the transaction (modelId).
|
||||
* @property reply_to_topic - The topic to reply to for the transaction.
|
||||
* @property idempotency_key - The idempotency key of the transaction.
|
||||
* @property action - The action of the transaction.
|
||||
* @property action_type - The type of the transaction.
|
||||
* @property attempt - The number of attempts for the transaction.
|
||||
* @property timestamp - The timestamp of the transaction.
|
||||
*/
|
||||
export type TransactionMetadata = {
|
||||
model_id: string
|
||||
reply_to_topic: string
|
||||
idempotency_key: string
|
||||
action: string
|
||||
action_type: TransactionHandlerType
|
||||
attempt: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef TransactionContext
|
||||
* @property payload - Object containing the initial payload.
|
||||
* @property invoke - Object containing responses of Invoke handlers on steps flagged with saveResponse.
|
||||
* @property compensate - Object containing responses of Compensate handlers on steps flagged with saveResponse.
|
||||
*/
|
||||
export class TransactionContext {
|
||||
constructor(
|
||||
public payload: unknown = undefined,
|
||||
public invoke: Record<string, unknown> = {},
|
||||
public compensate: Record<string, unknown> = {}
|
||||
) {}
|
||||
}
|
||||
|
||||
export class TransactionStepError {
|
||||
constructor(
|
||||
public action: string,
|
||||
public handlerType: TransactionHandlerType,
|
||||
public error: Error | any
|
||||
) {}
|
||||
}
|
||||
|
||||
export class TransactionCheckpoint {
|
||||
constructor(
|
||||
public flow: TransactionFlow,
|
||||
public context: TransactionContext,
|
||||
public errors: TransactionStepError[] = []
|
||||
) {}
|
||||
}
|
||||
|
||||
export class TransactionPayload {
|
||||
/**
|
||||
* @param metadata - The metadata of the transaction.
|
||||
* @param data - The initial payload data to begin a transation.
|
||||
* @param context - Object gathering responses of all steps flagged with saveResponse.
|
||||
*/
|
||||
constructor(
|
||||
public metadata: TransactionMetadata,
|
||||
public data: Record<string, unknown>,
|
||||
public context: TransactionContext
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* DistributedTransaction represents a distributed transaction, which is a transaction that is composed of multiple steps that are executed in a specific order.
|
||||
*/
|
||||
|
||||
export class DistributedTransaction {
|
||||
public modelId: string
|
||||
public transactionId: string
|
||||
|
||||
private errors: TransactionStepError[] = []
|
||||
|
||||
private context: TransactionContext = new TransactionContext()
|
||||
|
||||
constructor(
|
||||
private flow: TransactionFlow,
|
||||
public handler: (
|
||||
actionId: string,
|
||||
handlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) => Promise<unknown>,
|
||||
public payload?: any,
|
||||
errors?: TransactionStepError[],
|
||||
context?: TransactionContext
|
||||
) {
|
||||
this.transactionId = flow.transactionId
|
||||
this.modelId = flow.modelId
|
||||
|
||||
if (errors) {
|
||||
this.errors = errors
|
||||
}
|
||||
|
||||
this.context.payload = payload
|
||||
if (context) {
|
||||
this.context = { ...context }
|
||||
}
|
||||
}
|
||||
|
||||
public getFlow() {
|
||||
return this.flow
|
||||
}
|
||||
|
||||
public getContext() {
|
||||
return this.context
|
||||
}
|
||||
|
||||
public getErrors() {
|
||||
return this.errors
|
||||
}
|
||||
|
||||
public addError(
|
||||
action: string,
|
||||
handlerType: TransactionHandlerType,
|
||||
error: Error | any
|
||||
) {
|
||||
this.errors.push({
|
||||
action,
|
||||
handlerType,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
public addResponse(
|
||||
action: string,
|
||||
handlerType: TransactionHandlerType,
|
||||
response: unknown
|
||||
) {
|
||||
this.context[handlerType][action] = response
|
||||
}
|
||||
|
||||
public hasFinished(): boolean {
|
||||
return [
|
||||
TransactionState.DONE,
|
||||
TransactionState.REVERTED,
|
||||
TransactionState.FAILED,
|
||||
].includes(this.getState())
|
||||
}
|
||||
|
||||
public getState(): TransactionState {
|
||||
return this.getFlow().state
|
||||
}
|
||||
|
||||
public get isPartiallyCompleted(): boolean {
|
||||
return !!this.getFlow().hasFailedSteps || !!this.getFlow().hasSkippedSteps
|
||||
}
|
||||
|
||||
public canInvoke(): boolean {
|
||||
return (
|
||||
this.getFlow().state === TransactionState.NOT_STARTED ||
|
||||
this.getFlow().state === TransactionState.INVOKING
|
||||
)
|
||||
}
|
||||
public canRevert(): boolean {
|
||||
return (
|
||||
this.getFlow().state === TransactionState.DONE ||
|
||||
this.getFlow().state === TransactionState.COMPENSATING
|
||||
)
|
||||
}
|
||||
|
||||
public static keyValueStore: any = {} // TODO: Use Key/Value db
|
||||
private static keyPrefix = "dtrans:"
|
||||
public async saveCheckpoint(): Promise<TransactionCheckpoint> {
|
||||
// TODO: Use Key/Value db to save transactions
|
||||
const key = DistributedTransaction.keyPrefix + this.transactionId
|
||||
const data = new TransactionCheckpoint(
|
||||
this.getFlow(),
|
||||
this.getContext(),
|
||||
this.getErrors()
|
||||
)
|
||||
DistributedTransaction.keyValueStore[key] = JSON.stringify(data)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
public static async loadTransaction(
|
||||
transactionId: string
|
||||
): Promise<TransactionCheckpoint | null> {
|
||||
// TODO: Use Key/Value db to load transactions
|
||||
const key = DistributedTransaction.keyPrefix + transactionId
|
||||
if (DistributedTransaction.keyValueStore[key]) {
|
||||
return JSON.parse(DistributedTransaction.keyValueStore[key])
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
public async deleteCheckpoint(): Promise<void> {
|
||||
// TODO: Delete from Key/Value db
|
||||
const key = DistributedTransaction.keyPrefix + this.transactionId
|
||||
delete DistributedTransaction.keyValueStore[key]
|
||||
}
|
||||
}
|
||||
5
packages/orchestration/src/transaction/index.ts
Normal file
5
packages/orchestration/src/transaction/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./types"
|
||||
export * from "./transaction-orchestrator"
|
||||
export * from "./transaction-step"
|
||||
export * from "./distributed-transaction"
|
||||
export * from "./orchestrator-builder"
|
||||
431
packages/orchestration/src/transaction/orchestrator-builder.ts
Normal file
431
packages/orchestration/src/transaction/orchestrator-builder.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { TransactionStepsDefinition } from "./types"
|
||||
|
||||
interface InternalStep extends TransactionStepsDefinition {
|
||||
next?: InternalStep | InternalStep[]
|
||||
depth: number
|
||||
parent?: InternalStep | null
|
||||
}
|
||||
|
||||
export class OrchestratorBuilder {
|
||||
protected steps: InternalStep
|
||||
protected hasChanges_ = false
|
||||
|
||||
get hasChanges() {
|
||||
return this.hasChanges_
|
||||
}
|
||||
|
||||
constructor(steps?: TransactionStepsDefinition) {
|
||||
this.load(steps)
|
||||
}
|
||||
|
||||
load(steps?: TransactionStepsDefinition) {
|
||||
this.steps = {
|
||||
depth: -1,
|
||||
parent: null,
|
||||
next: steps
|
||||
? JSON.parse(
|
||||
JSON.stringify((steps.action ? steps : steps.next) as InternalStep)
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
|
||||
this.updateDepths(this.steps, {}, 1, -1)
|
||||
return this
|
||||
}
|
||||
|
||||
addAction(action: string, options: Partial<TransactionStepsDefinition> = {}) {
|
||||
const step = this.findLastStep()
|
||||
const newAction = {
|
||||
action,
|
||||
depth: step.depth + 1,
|
||||
parent: step.action,
|
||||
...options,
|
||||
} as InternalStep
|
||||
|
||||
step.next = newAction
|
||||
this.hasChanges_ = true
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
replaceAction(
|
||||
existingAction: string,
|
||||
action: string,
|
||||
options: Partial<TransactionStepsDefinition> = {}
|
||||
) {
|
||||
const step = this.findOrThrowStepByAction(existingAction)
|
||||
step.action = action
|
||||
|
||||
Object.assign(step, options)
|
||||
|
||||
this.hasChanges_ = true
|
||||
return this
|
||||
}
|
||||
|
||||
insertActionBefore(
|
||||
existingAction: string,
|
||||
action: string,
|
||||
options: Partial<TransactionStepsDefinition> = {}
|
||||
) {
|
||||
const parentStep = this.findParentStepByAction(existingAction)
|
||||
if (parentStep) {
|
||||
const oldNext = parentStep.next!
|
||||
const newDepth = parentStep.depth + 1
|
||||
if (Array.isArray(parentStep.next)) {
|
||||
const index = parentStep.next.findIndex(
|
||||
(step) => step.action === existingAction
|
||||
)
|
||||
if (index > -1) {
|
||||
parentStep.next[index] = {
|
||||
action,
|
||||
...options,
|
||||
next: oldNext[index],
|
||||
depth: newDepth,
|
||||
} as InternalStep
|
||||
}
|
||||
} else {
|
||||
parentStep.next = {
|
||||
action,
|
||||
...options,
|
||||
next: oldNext,
|
||||
depth: newDepth,
|
||||
} as InternalStep
|
||||
}
|
||||
|
||||
this.updateDepths(oldNext as InternalStep, parentStep)
|
||||
}
|
||||
|
||||
this.hasChanges_ = true
|
||||
return this
|
||||
}
|
||||
|
||||
insertActionAfter(
|
||||
existingAction: string,
|
||||
action: string,
|
||||
options: Partial<TransactionStepsDefinition> = {}
|
||||
) {
|
||||
const step = this.findOrThrowStepByAction(existingAction)
|
||||
const oldNext = step.next
|
||||
const newDepth = step.depth + 1
|
||||
step.next = {
|
||||
action,
|
||||
...options,
|
||||
next: oldNext,
|
||||
depth: newDepth,
|
||||
parent: step.action,
|
||||
} as InternalStep
|
||||
|
||||
this.updateDepths(oldNext as InternalStep, step.next)
|
||||
this.hasChanges_ = true
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
protected appendTo(step: InternalStep | string, newStep: InternalStep) {
|
||||
if (typeof step === "string") {
|
||||
step = this.findOrThrowStepByAction(step)
|
||||
}
|
||||
|
||||
step.next = {
|
||||
...newStep,
|
||||
depth: step.depth + 1,
|
||||
parent: step.action,
|
||||
} as InternalStep
|
||||
|
||||
this.hasChanges_ = true
|
||||
return this
|
||||
}
|
||||
|
||||
appendAction(
|
||||
action: string,
|
||||
to: string,
|
||||
options: Partial<TransactionStepsDefinition> = {}
|
||||
) {
|
||||
const newAction = {
|
||||
action,
|
||||
...options,
|
||||
} as InternalStep
|
||||
|
||||
const branch = this.findLastStep(this.findStepByAction(to))
|
||||
this.appendTo(branch, newAction)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
protected move(
|
||||
actionToMove: string,
|
||||
targetAction: string,
|
||||
{
|
||||
runInParallel,
|
||||
mergeNext,
|
||||
}: {
|
||||
runInParallel?: boolean
|
||||
mergeNext?: boolean
|
||||
} = {
|
||||
runInParallel: false,
|
||||
mergeNext: false,
|
||||
}
|
||||
): OrchestratorBuilder {
|
||||
const parentActionToMoveStep = this.findParentStepByAction(actionToMove)!
|
||||
const parentTargetActionStep = this.findParentStepByAction(targetAction)!
|
||||
const actionToMoveStep = this.findStepByAction(
|
||||
actionToMove,
|
||||
parentTargetActionStep
|
||||
)!
|
||||
|
||||
if (!actionToMoveStep) {
|
||||
throw new Error(
|
||||
`Action "${actionToMove}" could not be found in the following steps of "${targetAction}"`
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(parentActionToMoveStep.next)) {
|
||||
const index = parentActionToMoveStep.next.findIndex(
|
||||
(step) => step.action === actionToMove
|
||||
)
|
||||
if (index > -1) {
|
||||
parentActionToMoveStep.next.splice(index, 1)
|
||||
}
|
||||
} else {
|
||||
delete parentActionToMoveStep.next
|
||||
}
|
||||
|
||||
if (runInParallel) {
|
||||
if (Array.isArray(parentTargetActionStep.next)) {
|
||||
parentTargetActionStep.next.push(actionToMoveStep)
|
||||
} else if (parentTargetActionStep.next) {
|
||||
parentTargetActionStep.next = [
|
||||
parentTargetActionStep.next,
|
||||
actionToMoveStep,
|
||||
]
|
||||
}
|
||||
} else {
|
||||
if (actionToMoveStep.next) {
|
||||
if (mergeNext) {
|
||||
if (Array.isArray(actionToMoveStep.next)) {
|
||||
actionToMoveStep.next.push(
|
||||
parentTargetActionStep.next as InternalStep
|
||||
)
|
||||
} else {
|
||||
actionToMoveStep.next = [
|
||||
actionToMoveStep.next,
|
||||
parentTargetActionStep.next as InternalStep,
|
||||
]
|
||||
}
|
||||
} else {
|
||||
this.appendTo(
|
||||
this.findLastStep(actionToMoveStep),
|
||||
parentTargetActionStep.next as InternalStep
|
||||
)
|
||||
}
|
||||
} else {
|
||||
actionToMoveStep.next = parentTargetActionStep.next
|
||||
}
|
||||
|
||||
parentTargetActionStep.next = actionToMoveStep
|
||||
}
|
||||
|
||||
this.updateDepths(
|
||||
actionToMoveStep as InternalStep,
|
||||
parentTargetActionStep,
|
||||
1,
|
||||
parentTargetActionStep.depth
|
||||
)
|
||||
|
||||
this.hasChanges_ = true
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
moveAction(actionToMove: string, targetAction: string): OrchestratorBuilder {
|
||||
return this.move(actionToMove, targetAction)
|
||||
}
|
||||
|
||||
moveAndMergeNextAction(
|
||||
actionToMove: string,
|
||||
targetAction: string
|
||||
): OrchestratorBuilder {
|
||||
return this.move(actionToMove, targetAction, { mergeNext: true })
|
||||
}
|
||||
|
||||
mergeActions(where: string, ...actions: string[]) {
|
||||
actions.unshift(where)
|
||||
|
||||
if (actions.length < 2) {
|
||||
throw new Error("Cannot merge less than two actions")
|
||||
}
|
||||
|
||||
for (const action of actions) {
|
||||
if (action !== where) {
|
||||
this.move(action, where, { runInParallel: true })
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
deleteAction(action: string, steps: InternalStep = this.steps) {
|
||||
const actionStep = this.findOrThrowStepByAction(action)
|
||||
const parentStep = this.findParentStepByAction(action, steps)!
|
||||
|
||||
if (Array.isArray(parentStep.next)) {
|
||||
const index = parentStep.next.findIndex((step) => step.action === action)
|
||||
if (index > -1 && actionStep.next) {
|
||||
if (actionStep.next) {
|
||||
parentStep.next[index] = actionStep.next as InternalStep
|
||||
} else {
|
||||
parentStep.next.splice(index, 1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
parentStep.next = actionStep.next
|
||||
}
|
||||
|
||||
this.updateDepths(
|
||||
actionStep.next as InternalStep,
|
||||
parentStep,
|
||||
1,
|
||||
parentStep.depth
|
||||
)
|
||||
|
||||
this.hasChanges_ = true
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
pruneAction(action: string) {
|
||||
const actionStep = this.findOrThrowStepByAction(action)
|
||||
const parentStep = this.findParentStepByAction(action, this.steps)!
|
||||
|
||||
if (Array.isArray(parentStep.next)) {
|
||||
const index = parentStep.next.findIndex((step) => step.action === action)
|
||||
if (index > -1) {
|
||||
parentStep.next.splice(index, 1)
|
||||
}
|
||||
} else {
|
||||
delete parentStep.next
|
||||
}
|
||||
|
||||
this.hasChanges_ = true
|
||||
return this
|
||||
}
|
||||
|
||||
protected findStepByAction(
|
||||
action: string,
|
||||
step: InternalStep = this.steps
|
||||
): InternalStep | undefined {
|
||||
if (step.action === action) {
|
||||
return step
|
||||
}
|
||||
|
||||
if (Array.isArray(step.next)) {
|
||||
for (const subStep of step.next) {
|
||||
const found = this.findStepByAction(action, subStep as InternalStep)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
} else if (step.next && typeof step.next === "object") {
|
||||
return this.findStepByAction(action, step.next as InternalStep)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
protected findOrThrowStepByAction(
|
||||
action: string,
|
||||
steps: InternalStep = this.steps
|
||||
): InternalStep {
|
||||
const step = this.findStepByAction(action, steps)
|
||||
if (!step) {
|
||||
throw new Error(`Action "${action}" could not be found`)
|
||||
}
|
||||
|
||||
return step
|
||||
}
|
||||
|
||||
protected findParentStepByAction(
|
||||
action: string,
|
||||
step: InternalStep = this.steps
|
||||
): InternalStep | undefined {
|
||||
if (!step.next) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextSteps = Array.isArray(step.next) ? step.next : [step.next]
|
||||
for (const nextStep of nextSteps) {
|
||||
if (!nextStep) {
|
||||
continue
|
||||
}
|
||||
if (nextStep.action === action) {
|
||||
return step
|
||||
}
|
||||
const foundStep = this.findParentStepByAction(
|
||||
action,
|
||||
nextStep as InternalStep
|
||||
)
|
||||
if (foundStep) {
|
||||
return foundStep
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
protected findLastStep(steps: InternalStep = this.steps): InternalStep {
|
||||
let step = steps as InternalStep
|
||||
while (step.next) {
|
||||
step = Array.isArray(step.next)
|
||||
? (step.next[step.next.length - 1] as InternalStep)
|
||||
: (step.next as InternalStep)
|
||||
}
|
||||
|
||||
return step
|
||||
}
|
||||
|
||||
protected updateDepths(
|
||||
startingStep: InternalStep,
|
||||
parent,
|
||||
incr = 1,
|
||||
beginFrom?: number
|
||||
): void {
|
||||
if (!startingStep) {
|
||||
return
|
||||
}
|
||||
|
||||
const update = (step: InternalStep, parent, beginFrom) => {
|
||||
step.depth = beginFrom + incr
|
||||
step.parent = parent.action
|
||||
if (Array.isArray(step.next)) {
|
||||
step.next.forEach((nextAction) => update(nextAction, step, step.depth))
|
||||
} else if (step.next) {
|
||||
update(step.next, step, step.depth)
|
||||
}
|
||||
}
|
||||
update(startingStep, parent, beginFrom ?? startingStep.depth)
|
||||
}
|
||||
|
||||
build(): TransactionStepsDefinition {
|
||||
if (!this.steps.next) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const ignore = ["depth", "parent"]
|
||||
const result = JSON.parse(
|
||||
JSON.stringify(
|
||||
Array.isArray(this.steps.next) ? this.steps : this.steps.next,
|
||||
null
|
||||
),
|
||||
(key, value) => {
|
||||
if (ignore.includes(key)) {
|
||||
return
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
)
|
||||
|
||||
this.hasChanges_ = false
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
import {
|
||||
DistributedTransaction,
|
||||
TransactionCheckpoint,
|
||||
TransactionPayload,
|
||||
} from "./distributed-transaction"
|
||||
import {
|
||||
TransactionHandlerType,
|
||||
TransactionModel,
|
||||
TransactionState,
|
||||
TransactionStepStatus,
|
||||
TransactionStepsDefinition,
|
||||
} from "./types"
|
||||
import { TransactionStep, TransactionStepHandler } from "./transaction-step"
|
||||
|
||||
import { EventEmitter } from "events"
|
||||
|
||||
export type TransactionFlow = {
|
||||
modelId: string
|
||||
definition: TransactionStepsDefinition
|
||||
transactionId: string
|
||||
hasFailedSteps: boolean
|
||||
hasSkippedSteps: boolean
|
||||
state: TransactionState
|
||||
steps: {
|
||||
[key: string]: TransactionStep
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @class TransactionOrchestrator is responsible for managing and executing distributed transactions.
|
||||
* It is based on a single transaction definition, which is used to execute all the transaction steps
|
||||
*/
|
||||
export class TransactionOrchestrator extends EventEmitter {
|
||||
private static ROOT_STEP = "_root"
|
||||
private invokeSteps: string[] = []
|
||||
private compensateSteps: string[] = []
|
||||
|
||||
public static DEFAULT_RETRIES = 0
|
||||
constructor(
|
||||
public id: string,
|
||||
private definition: TransactionStepsDefinition
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
private static SEPARATOR = ":"
|
||||
public static getKeyName(...params: string[]): string {
|
||||
return params.join(this.SEPARATOR)
|
||||
}
|
||||
private getPreviousStep(flow: TransactionFlow, step: TransactionStep) {
|
||||
const id = step.id.split(".")
|
||||
id.pop()
|
||||
const parentId = id.join(".")
|
||||
return flow.steps[parentId]
|
||||
}
|
||||
|
||||
private getInvokeSteps(flow: TransactionFlow): string[] {
|
||||
if (this.invokeSteps.length) {
|
||||
return this.invokeSteps
|
||||
}
|
||||
|
||||
const steps = Object.keys(flow.steps)
|
||||
|
||||
steps.sort((a, b) => flow.steps[a].depth - flow.steps[b].depth)
|
||||
this.invokeSteps = steps
|
||||
|
||||
return steps
|
||||
}
|
||||
|
||||
private getCompensationSteps(flow: TransactionFlow): string[] {
|
||||
if (this.compensateSteps.length) {
|
||||
return this.compensateSteps
|
||||
}
|
||||
|
||||
const steps = Object.keys(flow.steps)
|
||||
steps.sort(
|
||||
(a, b) => (flow.steps[b].depth || 0) - (flow.steps[a].depth || 0)
|
||||
)
|
||||
this.compensateSteps = steps
|
||||
|
||||
return steps
|
||||
}
|
||||
|
||||
private canMoveForward(flow: TransactionFlow, previousStep: TransactionStep) {
|
||||
const states = [
|
||||
TransactionState.DONE,
|
||||
TransactionState.FAILED,
|
||||
TransactionState.SKIPPED,
|
||||
]
|
||||
|
||||
const siblings = this.getPreviousStep(flow, previousStep).next.map(
|
||||
(sib) => flow.steps[sib]
|
||||
)
|
||||
|
||||
return (
|
||||
!!previousStep.definition.noWait ||
|
||||
siblings.every((sib) => states.includes(sib.invoke.state))
|
||||
)
|
||||
}
|
||||
|
||||
private canMoveBackward(flow: TransactionFlow, step: TransactionStep) {
|
||||
const states = [
|
||||
TransactionState.DONE,
|
||||
TransactionState.REVERTED,
|
||||
TransactionState.FAILED,
|
||||
TransactionState.DORMANT,
|
||||
]
|
||||
const siblings = step.next.map((sib) => flow.steps[sib])
|
||||
return (
|
||||
siblings.length === 0 ||
|
||||
siblings.every((sib) => states.includes(sib.compensate.state))
|
||||
)
|
||||
}
|
||||
|
||||
private canContinue(flow: TransactionFlow, step: TransactionStep): boolean {
|
||||
if (flow.state == TransactionState.COMPENSATING) {
|
||||
return this.canMoveBackward(flow, step)
|
||||
} else {
|
||||
const previous = this.getPreviousStep(flow, step)
|
||||
if (previous.id === TransactionOrchestrator.ROOT_STEP) {
|
||||
return true
|
||||
}
|
||||
|
||||
return this.canMoveForward(flow, previous)
|
||||
}
|
||||
}
|
||||
|
||||
private checkAllSteps(transaction: DistributedTransaction): {
|
||||
next: TransactionStep[]
|
||||
total: number
|
||||
remaining: number
|
||||
completed: number
|
||||
} {
|
||||
let hasSkipped = false
|
||||
let hasIgnoredFailure = false
|
||||
let hasFailed = false
|
||||
let hasWaiting = false
|
||||
let hasReverted = false
|
||||
let completedSteps = 0
|
||||
|
||||
const flow = transaction.getFlow()
|
||||
|
||||
const nextSteps: TransactionStep[] = []
|
||||
const allSteps =
|
||||
flow.state === TransactionState.COMPENSATING
|
||||
? this.getCompensationSteps(flow)
|
||||
: this.getInvokeSteps(flow)
|
||||
|
||||
for (const step of allSteps) {
|
||||
if (
|
||||
step === TransactionOrchestrator.ROOT_STEP ||
|
||||
!this.canContinue(flow, flow.steps[step])
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const stepDef = flow.steps[step]
|
||||
const curState = stepDef.getStates()
|
||||
|
||||
if (curState.status === TransactionStepStatus.WAITING) {
|
||||
hasWaiting = true
|
||||
if (stepDef.canRetry()) {
|
||||
nextSteps.push(stepDef)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (stepDef.canInvoke(flow.state) || stepDef.canCompensate(flow.state)) {
|
||||
nextSteps.push(stepDef)
|
||||
} else {
|
||||
completedSteps++
|
||||
|
||||
if (curState.state === TransactionState.SKIPPED) {
|
||||
hasSkipped = true
|
||||
} else if (curState.state === TransactionState.REVERTED) {
|
||||
hasReverted = true
|
||||
} else if (curState.state === TransactionState.FAILED) {
|
||||
if (stepDef.definition.continueOnPermanentFailure) {
|
||||
hasIgnoredFailure = true
|
||||
} else {
|
||||
hasFailed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalSteps = allSteps.length - 1
|
||||
if (
|
||||
flow.state === TransactionState.WAITING_TO_COMPENSATE &&
|
||||
nextSteps.length === 0 &&
|
||||
!hasWaiting
|
||||
) {
|
||||
flow.state = TransactionState.COMPENSATING
|
||||
this.flagStepsToRevert(flow)
|
||||
|
||||
this.emit("compensate", transaction)
|
||||
|
||||
return this.checkAllSteps(transaction)
|
||||
} else if (completedSteps === totalSteps) {
|
||||
if (hasSkipped) {
|
||||
flow.hasSkippedSteps = true
|
||||
}
|
||||
if (hasIgnoredFailure) {
|
||||
flow.hasFailedSteps = true
|
||||
}
|
||||
if (hasFailed) {
|
||||
flow.state = TransactionState.FAILED
|
||||
} else {
|
||||
flow.state = hasReverted
|
||||
? TransactionState.REVERTED
|
||||
: TransactionState.DONE
|
||||
}
|
||||
|
||||
this.emit("finish", transaction)
|
||||
|
||||
// TODO: check TransactionModel if it should delete the checkpoint when the transaction is done
|
||||
void transaction.deleteCheckpoint()
|
||||
}
|
||||
|
||||
return {
|
||||
next: nextSteps,
|
||||
total: totalSteps,
|
||||
remaining: totalSteps - completedSteps,
|
||||
completed: completedSteps,
|
||||
}
|
||||
}
|
||||
|
||||
private flagStepsToRevert(flow: TransactionFlow): void {
|
||||
for (const step in flow.steps) {
|
||||
if (step === TransactionOrchestrator.ROOT_STEP) {
|
||||
continue
|
||||
}
|
||||
|
||||
const stepDef = flow.steps[step]
|
||||
const curState = stepDef.getStates()
|
||||
if (
|
||||
(curState.state === TransactionState.DONE ||
|
||||
curState.status === TransactionStepStatus.PERMANENT_FAILURE) &&
|
||||
!stepDef.definition.noCompensation
|
||||
) {
|
||||
stepDef.beginCompensation()
|
||||
stepDef.changeState(TransactionState.NOT_STARTED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async setStepSuccess(
|
||||
transaction: DistributedTransaction,
|
||||
step: TransactionStep,
|
||||
response: unknown
|
||||
): Promise<void> {
|
||||
if (step.saveResponse) {
|
||||
transaction.addResponse(
|
||||
step.definition.action!,
|
||||
step.isCompensating()
|
||||
? TransactionHandlerType.COMPENSATE
|
||||
: TransactionHandlerType.INVOKE,
|
||||
response
|
||||
)
|
||||
}
|
||||
|
||||
step.changeStatus(TransactionStepStatus.OK)
|
||||
|
||||
if (step.isCompensating()) {
|
||||
step.changeState(TransactionState.REVERTED)
|
||||
} else {
|
||||
step.changeState(TransactionState.DONE)
|
||||
}
|
||||
|
||||
if (step.definition.async) {
|
||||
await transaction.saveCheckpoint()
|
||||
}
|
||||
}
|
||||
|
||||
private static async setStepFailure(
|
||||
transaction: DistributedTransaction,
|
||||
step: TransactionStep,
|
||||
error: Error | any,
|
||||
maxRetries: number = TransactionOrchestrator.DEFAULT_RETRIES
|
||||
): Promise<void> {
|
||||
step.failures++
|
||||
|
||||
step.changeStatus(TransactionStepStatus.TEMPORARY_FAILURE)
|
||||
|
||||
if (step.failures > maxRetries) {
|
||||
step.changeState(TransactionState.FAILED)
|
||||
step.changeStatus(TransactionStepStatus.PERMANENT_FAILURE)
|
||||
|
||||
transaction.addError(
|
||||
step.definition.action!,
|
||||
step.isCompensating()
|
||||
? TransactionHandlerType.COMPENSATE
|
||||
: TransactionHandlerType.INVOKE,
|
||||
error
|
||||
)
|
||||
|
||||
if (!step.isCompensating()) {
|
||||
const flow = transaction.getFlow()
|
||||
if (step.definition.continueOnPermanentFailure) {
|
||||
for (const childStep of step.next) {
|
||||
const child = flow.steps[childStep]
|
||||
child.changeState(TransactionState.SKIPPED)
|
||||
}
|
||||
} else {
|
||||
flow.state = TransactionState.WAITING_TO_COMPENSATE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (step.definition.async) {
|
||||
await transaction.saveCheckpoint()
|
||||
}
|
||||
}
|
||||
|
||||
private async executeNext(
|
||||
transaction: DistributedTransaction
|
||||
): Promise<void> {
|
||||
if (transaction.hasFinished()) {
|
||||
return
|
||||
}
|
||||
|
||||
const flow = transaction.getFlow()
|
||||
const nextSteps = this.checkAllSteps(transaction)
|
||||
const execution: Promise<void | unknown>[] = []
|
||||
|
||||
for (const step of nextSteps.next) {
|
||||
const curState = step.getStates()
|
||||
const type = step.isCompensating()
|
||||
? TransactionHandlerType.COMPENSATE
|
||||
: TransactionHandlerType.INVOKE
|
||||
|
||||
step.lastAttempt = Date.now()
|
||||
step.attempts++
|
||||
|
||||
if (curState.state === TransactionState.NOT_STARTED) {
|
||||
if (step.isCompensating()) {
|
||||
step.changeState(TransactionState.COMPENSATING)
|
||||
} else if (flow.state === TransactionState.INVOKING) {
|
||||
step.changeState(TransactionState.INVOKING)
|
||||
}
|
||||
}
|
||||
|
||||
step.changeStatus(TransactionStepStatus.WAITING)
|
||||
|
||||
const payload = new TransactionPayload(
|
||||
{
|
||||
model_id: flow.modelId,
|
||||
reply_to_topic: TransactionOrchestrator.getKeyName(
|
||||
"trans",
|
||||
flow.modelId
|
||||
),
|
||||
idempotency_key: TransactionOrchestrator.getKeyName(
|
||||
flow.transactionId,
|
||||
step.definition.action!,
|
||||
type
|
||||
),
|
||||
action: step.definition.action + "",
|
||||
action_type: type,
|
||||
attempt: step.attempts,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
transaction.payload,
|
||||
transaction.getContext()
|
||||
)
|
||||
|
||||
if (!step.definition.async) {
|
||||
execution.push(
|
||||
transaction
|
||||
.handler(step.definition.action + "", type, payload)
|
||||
.then(async (response) => {
|
||||
await TransactionOrchestrator.setStepSuccess(
|
||||
transaction,
|
||||
step,
|
||||
response
|
||||
)
|
||||
})
|
||||
.catch(async (error) => {
|
||||
await TransactionOrchestrator.setStepFailure(
|
||||
transaction,
|
||||
step,
|
||||
error,
|
||||
step.definition.maxRetries
|
||||
)
|
||||
})
|
||||
)
|
||||
} else {
|
||||
execution.push(
|
||||
transaction.saveCheckpoint().then(async () =>
|
||||
transaction
|
||||
.handler(step.definition.action + "", type, payload)
|
||||
.catch(async (error) => {
|
||||
await TransactionOrchestrator.setStepFailure(
|
||||
transaction,
|
||||
step,
|
||||
error,
|
||||
step.definition.maxRetries
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(execution)
|
||||
|
||||
if (nextSteps.next.length > 0) {
|
||||
await this.executeNext(transaction)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new transaction or resume a transaction that has been previously started
|
||||
* @param transaction - The transaction to resume
|
||||
*/
|
||||
public async resume(transaction: DistributedTransaction): Promise<void> {
|
||||
if (transaction.modelId !== this.id) {
|
||||
throw new Error(
|
||||
`TransactionModel "${transaction.modelId}" cannot be orchestrated by "${this.id}" model.`
|
||||
)
|
||||
}
|
||||
|
||||
if (transaction.hasFinished()) {
|
||||
return
|
||||
}
|
||||
|
||||
const flow = transaction.getFlow()
|
||||
|
||||
if (flow.state === TransactionState.NOT_STARTED) {
|
||||
flow.state = TransactionState.INVOKING
|
||||
this.emit("begin", transaction)
|
||||
} else {
|
||||
this.emit("resume", transaction)
|
||||
}
|
||||
|
||||
await this.executeNext(transaction)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel and revert a transaction compensating all its executed steps. It can be an ongoing transaction or a completed one
|
||||
* @param transaction - The transaction to be reverted
|
||||
*/
|
||||
public async cancelTransaction(
|
||||
transaction: DistributedTransaction
|
||||
): Promise<void> {
|
||||
if (transaction.modelId !== this.id) {
|
||||
throw new Error(
|
||||
`TransactionModel "${transaction.modelId}" cannot be orchestrated by "${this.id}" model.`
|
||||
)
|
||||
}
|
||||
|
||||
const flow = transaction.getFlow()
|
||||
if (flow.state === TransactionState.FAILED) {
|
||||
throw new Error(`Cannot revert a perment failed transaction.`)
|
||||
}
|
||||
|
||||
flow.state = TransactionState.WAITING_TO_COMPENSATE
|
||||
|
||||
await this.executeNext(transaction)
|
||||
}
|
||||
|
||||
private async createTransactionFlow(
|
||||
transactionId: string
|
||||
): Promise<TransactionFlow> {
|
||||
return {
|
||||
modelId: this.id,
|
||||
transactionId: transactionId,
|
||||
hasFailedSteps: false,
|
||||
hasSkippedSteps: false,
|
||||
state: TransactionState.NOT_STARTED,
|
||||
definition: this.definition,
|
||||
steps: TransactionOrchestrator.buildSteps(this.definition),
|
||||
}
|
||||
}
|
||||
|
||||
private static async loadTransactionById(
|
||||
transactionId: string
|
||||
): Promise<TransactionCheckpoint | null> {
|
||||
const transaction = await DistributedTransaction.loadTransaction(
|
||||
transactionId
|
||||
)
|
||||
|
||||
if (transaction !== null) {
|
||||
const flow = transaction.flow
|
||||
transaction.flow.steps = TransactionOrchestrator.buildSteps(
|
||||
flow.definition,
|
||||
flow.steps
|
||||
)
|
||||
return transaction
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private static buildSteps(
|
||||
flow: TransactionStepsDefinition,
|
||||
existingSteps?: { [key: string]: TransactionStep }
|
||||
): { [key: string]: TransactionStep } {
|
||||
const states: { [key: string]: TransactionStep } = {
|
||||
[TransactionOrchestrator.ROOT_STEP]: {
|
||||
id: TransactionOrchestrator.ROOT_STEP,
|
||||
next: [] as string[],
|
||||
} as TransactionStep,
|
||||
}
|
||||
|
||||
const actionNames = new Set<string>()
|
||||
const queue: any[] = [
|
||||
{ obj: flow, level: [TransactionOrchestrator.ROOT_STEP] },
|
||||
]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { obj, level } = queue.shift()
|
||||
|
||||
for (const key in obj) {
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (!obj.hasOwnProperty(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
queue.push({ obj: obj[key], level: [...level] })
|
||||
} else if (key === "action") {
|
||||
if (actionNames.has(obj.action)) {
|
||||
throw new Error(`Action "${obj.action}" is already defined.`)
|
||||
}
|
||||
|
||||
actionNames.add(obj.action)
|
||||
level.push(obj.action)
|
||||
const id = level.join(".")
|
||||
const parent = level.slice(0, level.length - 1).join(".")
|
||||
|
||||
states[parent].next?.push(id)
|
||||
|
||||
const definitionCopy = { ...obj }
|
||||
delete definitionCopy.next
|
||||
|
||||
states[id] = Object.assign(
|
||||
new TransactionStep(),
|
||||
existingSteps?.[id] || {
|
||||
id,
|
||||
depth: level.length - 1,
|
||||
definition: definitionCopy,
|
||||
saveResponse: definitionCopy.saveResponse ?? true,
|
||||
invoke: {
|
||||
state: TransactionState.NOT_STARTED,
|
||||
status: TransactionStepStatus.IDLE,
|
||||
},
|
||||
compensate: {
|
||||
state: TransactionState.DORMANT,
|
||||
status: TransactionStepStatus.IDLE,
|
||||
},
|
||||
attempts: 0,
|
||||
failures: 0,
|
||||
lastAttempt: null,
|
||||
next: [],
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return states
|
||||
}
|
||||
|
||||
/** Create a new transaction
|
||||
* @param transactionId - unique identifier of the transaction
|
||||
* @param handler - function to handle action of the transaction
|
||||
* @param payload - payload to be passed to all the transaction steps
|
||||
*/
|
||||
public async beginTransaction(
|
||||
transactionId: string,
|
||||
handler: TransactionStepHandler,
|
||||
payload?: unknown
|
||||
): Promise<DistributedTransaction> {
|
||||
const existingTransaction =
|
||||
await TransactionOrchestrator.loadTransactionById(transactionId)
|
||||
|
||||
let newTransaction = false
|
||||
let modelFlow
|
||||
if (!existingTransaction) {
|
||||
modelFlow = await this.createTransactionFlow(transactionId)
|
||||
newTransaction = true
|
||||
} else {
|
||||
modelFlow = existingTransaction.flow
|
||||
}
|
||||
|
||||
const transaction = new DistributedTransaction(
|
||||
modelFlow,
|
||||
handler,
|
||||
payload,
|
||||
existingTransaction?.errors,
|
||||
existingTransaction?.context
|
||||
)
|
||||
if (newTransaction) {
|
||||
await transaction.saveCheckpoint()
|
||||
}
|
||||
|
||||
return transaction
|
||||
}
|
||||
|
||||
private static getStepByAction(
|
||||
flow: TransactionFlow,
|
||||
action: string
|
||||
): TransactionStep | null {
|
||||
for (const key in flow.steps) {
|
||||
if (action === flow.steps[key]?.definition?.action) {
|
||||
return flow.steps[key]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private static async getTransactionAndStepFromIdempotencyKey(
|
||||
responseIdempotencyKey: string,
|
||||
handler?: TransactionStepHandler,
|
||||
transaction?: DistributedTransaction
|
||||
): Promise<[DistributedTransaction, TransactionStep]> {
|
||||
const [transactionId, action, actionType] = responseIdempotencyKey.split(
|
||||
TransactionOrchestrator.SEPARATOR
|
||||
)
|
||||
|
||||
if (!transaction && !handler) {
|
||||
throw new Error(
|
||||
"If a transaction is not provided, the handler is required"
|
||||
)
|
||||
}
|
||||
|
||||
if (!transaction) {
|
||||
const existingTransaction =
|
||||
await TransactionOrchestrator.loadTransactionById(transactionId)
|
||||
|
||||
if (existingTransaction === null) {
|
||||
throw new Error(`Transaction ${transactionId} could not be found.`)
|
||||
}
|
||||
|
||||
transaction = new DistributedTransaction(
|
||||
existingTransaction.flow,
|
||||
handler!,
|
||||
undefined,
|
||||
existingTransaction.errors,
|
||||
existingTransaction.context
|
||||
)
|
||||
}
|
||||
|
||||
const step = TransactionOrchestrator.getStepByAction(
|
||||
transaction.getFlow(),
|
||||
action
|
||||
)
|
||||
|
||||
if (step === null) {
|
||||
throw new Error("Action not found.")
|
||||
} else if (
|
||||
step.isCompensating()
|
||||
? actionType !== TransactionHandlerType.COMPENSATE
|
||||
: actionType !== TransactionHandlerType.INVOKE
|
||||
) {
|
||||
throw new Error("Incorrect action type.")
|
||||
}
|
||||
return [transaction, step]
|
||||
}
|
||||
|
||||
/** Register a step success for a specific transaction and step
|
||||
* @param responseIdempotencyKey - The idempotency key for the step
|
||||
* @param handler - The handler function to execute the step
|
||||
* @param transaction - The current transaction. If not provided it will be loaded based on the responseIdempotencyKey
|
||||
* @param response - The response of the step
|
||||
*/
|
||||
public async registerStepSuccess(
|
||||
responseIdempotencyKey: string,
|
||||
handler?: TransactionStepHandler,
|
||||
transaction?: DistributedTransaction,
|
||||
response?: unknown
|
||||
): Promise<DistributedTransaction> {
|
||||
const [curTransaction, step] =
|
||||
await TransactionOrchestrator.getTransactionAndStepFromIdempotencyKey(
|
||||
responseIdempotencyKey,
|
||||
handler,
|
||||
transaction
|
||||
)
|
||||
|
||||
if (step.getStates().status === TransactionStepStatus.WAITING) {
|
||||
await TransactionOrchestrator.setStepSuccess(
|
||||
curTransaction,
|
||||
step,
|
||||
response
|
||||
)
|
||||
|
||||
this.emit("resume", curTransaction)
|
||||
await this.executeNext(curTransaction)
|
||||
} else {
|
||||
throw new Error(
|
||||
`Cannot set step success when status is ${step.getStates().status}`
|
||||
)
|
||||
}
|
||||
|
||||
return curTransaction
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a step failure for a specific transaction and step
|
||||
* @param responseIdempotencyKey - The idempotency key for the step
|
||||
* @param error - The error that caused the failure
|
||||
* @param handler - The handler function to execute the step
|
||||
* @param transaction - The current transaction
|
||||
* @param response - The response of the step
|
||||
*/
|
||||
public async registerStepFailure(
|
||||
responseIdempotencyKey: string,
|
||||
error?: Error | any,
|
||||
handler?: TransactionStepHandler,
|
||||
transaction?: DistributedTransaction
|
||||
): Promise<DistributedTransaction> {
|
||||
const [curTransaction, step] =
|
||||
await TransactionOrchestrator.getTransactionAndStepFromIdempotencyKey(
|
||||
responseIdempotencyKey,
|
||||
handler,
|
||||
transaction
|
||||
)
|
||||
|
||||
if (step.getStates().status === TransactionStepStatus.WAITING) {
|
||||
await TransactionOrchestrator.setStepFailure(
|
||||
curTransaction,
|
||||
step,
|
||||
error,
|
||||
0
|
||||
)
|
||||
this.emit("resume", curTransaction)
|
||||
await this.executeNext(curTransaction)
|
||||
} else {
|
||||
throw new Error(
|
||||
`Cannot set step failure when status is ${step.getStates().status}`
|
||||
)
|
||||
}
|
||||
|
||||
return curTransaction
|
||||
}
|
||||
}
|
||||
157
packages/orchestration/src/transaction/transaction-step.ts
Normal file
157
packages/orchestration/src/transaction/transaction-step.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { TransactionPayload } from "./distributed-transaction"
|
||||
import {
|
||||
TransactionStepsDefinition,
|
||||
TransactionStepStatus,
|
||||
TransactionState,
|
||||
TransactionHandlerType,
|
||||
} from "./types"
|
||||
|
||||
export type TransactionStepHandler = (
|
||||
actionId: string,
|
||||
handlerType: TransactionHandlerType,
|
||||
payload: TransactionPayload
|
||||
) => Promise<unknown>
|
||||
|
||||
/**
|
||||
* @class TransactionStep
|
||||
* @classdesc A class representing a single step in a transaction flow
|
||||
*/
|
||||
export class TransactionStep {
|
||||
/**
|
||||
* @member id - The id of the step
|
||||
* @member depth - The depth of the step in the flow
|
||||
* @member definition - The definition of the step
|
||||
* @member invoke - The current state and status of the invoke action of the step
|
||||
* @member compensate - The current state and status of the compensate action of the step
|
||||
* @member attempts - The number of attempts made to execute the step
|
||||
* @member failures - The number of failures encountered while executing the step
|
||||
* @member lastAttempt - The timestamp of the last attempt made to execute the step
|
||||
* @member next - The ids of the next steps in the flow
|
||||
* @member saveResponse - A flag indicating if the response of a step should be shared in the transaction context and available to subsequent steps - default is true
|
||||
*/
|
||||
private stepFailed = false
|
||||
id: string
|
||||
depth: number
|
||||
definition: TransactionStepsDefinition
|
||||
invoke: {
|
||||
state: TransactionState
|
||||
status: TransactionStepStatus
|
||||
}
|
||||
compensate: {
|
||||
state: TransactionState
|
||||
status: TransactionStepStatus
|
||||
}
|
||||
attempts: number
|
||||
failures: number
|
||||
lastAttempt: number | null
|
||||
next: string[]
|
||||
saveResponse: boolean
|
||||
|
||||
public getStates() {
|
||||
return this.isCompensating() ? this.compensate : this.invoke
|
||||
}
|
||||
|
||||
public beginCompensation() {
|
||||
if (this.isCompensating()) {
|
||||
return
|
||||
}
|
||||
|
||||
this.stepFailed = true
|
||||
this.attempts = 0
|
||||
this.failures = 0
|
||||
this.lastAttempt = null
|
||||
}
|
||||
|
||||
public isCompensating() {
|
||||
return this.stepFailed
|
||||
}
|
||||
|
||||
public changeState(toState: TransactionState) {
|
||||
const allowed = {
|
||||
[TransactionState.DORMANT]: [TransactionState.NOT_STARTED],
|
||||
[TransactionState.NOT_STARTED]: [
|
||||
TransactionState.INVOKING,
|
||||
TransactionState.COMPENSATING,
|
||||
TransactionState.FAILED,
|
||||
TransactionState.SKIPPED,
|
||||
],
|
||||
[TransactionState.INVOKING]: [
|
||||
TransactionState.FAILED,
|
||||
TransactionState.DONE,
|
||||
],
|
||||
[TransactionState.COMPENSATING]: [
|
||||
TransactionState.REVERTED,
|
||||
TransactionState.FAILED,
|
||||
],
|
||||
[TransactionState.DONE]: [TransactionState.COMPENSATING],
|
||||
}
|
||||
|
||||
const curState = this.getStates()
|
||||
if (
|
||||
curState.state === toState ||
|
||||
allowed?.[curState.state]?.includes(toState)
|
||||
) {
|
||||
curState.state = toState
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Updating State from "${curState.state}" to "${toState}" is not allowed.`
|
||||
)
|
||||
}
|
||||
|
||||
public changeStatus(toStatus: TransactionStepStatus) {
|
||||
const allowed = {
|
||||
[TransactionStepStatus.WAITING]: [
|
||||
TransactionStepStatus.OK,
|
||||
TransactionStepStatus.TEMPORARY_FAILURE,
|
||||
TransactionStepStatus.PERMANENT_FAILURE,
|
||||
],
|
||||
[TransactionStepStatus.TEMPORARY_FAILURE]: [
|
||||
TransactionStepStatus.IDLE,
|
||||
TransactionStepStatus.PERMANENT_FAILURE,
|
||||
],
|
||||
[TransactionStepStatus.PERMANENT_FAILURE]: [TransactionStepStatus.IDLE],
|
||||
}
|
||||
|
||||
const curState = this.getStates()
|
||||
if (
|
||||
curState.status === toStatus ||
|
||||
toStatus === TransactionStepStatus.WAITING ||
|
||||
allowed?.[curState.status]?.includes(toStatus)
|
||||
) {
|
||||
curState.status = toStatus
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Updating Status from "${curState.status}" to "${toStatus}" is not allowed.`
|
||||
)
|
||||
}
|
||||
|
||||
canRetry(): boolean {
|
||||
return !!(
|
||||
this.lastAttempt &&
|
||||
this.definition.retryInterval &&
|
||||
Date.now() - this.lastAttempt > this.definition.retryInterval * 1e3
|
||||
)
|
||||
}
|
||||
|
||||
canInvoke(flowState: TransactionState): boolean {
|
||||
const { status, state } = this.getStates()
|
||||
return (
|
||||
(!this.isCompensating() &&
|
||||
state === TransactionState.NOT_STARTED &&
|
||||
flowState === TransactionState.INVOKING) ||
|
||||
status === TransactionStepStatus.TEMPORARY_FAILURE
|
||||
)
|
||||
}
|
||||
|
||||
canCompensate(flowState: TransactionState): boolean {
|
||||
return (
|
||||
this.isCompensating() &&
|
||||
this.getStates().state === TransactionState.NOT_STARTED &&
|
||||
flowState === TransactionState.COMPENSATING
|
||||
)
|
||||
}
|
||||
}
|
||||
43
packages/orchestration/src/transaction/types.ts
Normal file
43
packages/orchestration/src/transaction/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export enum TransactionHandlerType {
|
||||
INVOKE = "invoke",
|
||||
COMPENSATE = "compensate",
|
||||
}
|
||||
|
||||
export type TransactionStepsDefinition = {
|
||||
action?: string
|
||||
continueOnPermanentFailure?: boolean
|
||||
noCompensation?: boolean
|
||||
maxRetries?: number
|
||||
retryInterval?: number
|
||||
timeout?: number
|
||||
async?: boolean
|
||||
noWait?: boolean
|
||||
saveResponse?: boolean
|
||||
next?: TransactionStepsDefinition | TransactionStepsDefinition[]
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
export type TransactionModel = {
|
||||
id: string
|
||||
flow: TransactionStepsDefinition
|
||||
hash: string
|
||||
}
|
||||
94
packages/orchestration/src/workflow/global-workflow.ts
Normal file
94
packages/orchestration/src/workflow/global-workflow.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Context, LoadedModule, MedusaContainer } from "@medusajs/types"
|
||||
import { WorkflowDefinition, WorkflowManager } from "./workflow-manager"
|
||||
|
||||
import { DistributedTransaction } from "../transaction"
|
||||
import { asValue } from "awilix"
|
||||
import { createMedusaContainer } from "@medusajs/utils"
|
||||
|
||||
export class GlobalWorkflow extends WorkflowManager {
|
||||
protected static workflows: Map<string, WorkflowDefinition> = new Map()
|
||||
protected container: MedusaContainer
|
||||
protected context: Context
|
||||
|
||||
constructor(
|
||||
modulesLoaded?: LoadedModule[] | MedusaContainer,
|
||||
context?: Context
|
||||
) {
|
||||
super()
|
||||
|
||||
const container = createMedusaContainer()
|
||||
|
||||
// Medusa container
|
||||
if (!Array.isArray(modulesLoaded) && modulesLoaded) {
|
||||
const cradle = modulesLoaded.cradle
|
||||
for (const key in cradle) {
|
||||
container.register(key, asValue(cradle[key]))
|
||||
}
|
||||
}
|
||||
// Array of modules
|
||||
else if (modulesLoaded?.length) {
|
||||
for (const mod of modulesLoaded) {
|
||||
const registrationName = mod.__definition.registrationName
|
||||
container.register(registrationName, asValue(mod))
|
||||
}
|
||||
}
|
||||
|
||||
this.container = container
|
||||
this.context = context ?? {}
|
||||
}
|
||||
|
||||
async run(workflowId: string, uniqueTransactionId: string, input?: unknown) {
|
||||
if (!WorkflowManager.workflows.has(workflowId)) {
|
||||
throw new Error(`Workflow with id "${workflowId}" not found.`)
|
||||
}
|
||||
|
||||
const workflow = WorkflowManager.workflows.get(workflowId)!
|
||||
|
||||
const orchestrator = workflow.orchestrator
|
||||
|
||||
const transaction = await orchestrator.beginTransaction(
|
||||
uniqueTransactionId,
|
||||
workflow.handler(this.container, this.context),
|
||||
input
|
||||
)
|
||||
|
||||
await orchestrator.resume(transaction)
|
||||
|
||||
return transaction
|
||||
}
|
||||
|
||||
async registerStepSuccess(
|
||||
workflowId: string,
|
||||
idempotencyKey: string,
|
||||
response?: unknown
|
||||
): Promise<DistributedTransaction> {
|
||||
if (!WorkflowManager.workflows.has(workflowId)) {
|
||||
throw new Error(`Workflow with id "${workflowId}" not found.`)
|
||||
}
|
||||
|
||||
const workflow = WorkflowManager.workflows.get(workflowId)!
|
||||
return await workflow.orchestrator.registerStepSuccess(
|
||||
idempotencyKey,
|
||||
workflow.handler(this.container, this.context),
|
||||
undefined,
|
||||
response
|
||||
)
|
||||
}
|
||||
|
||||
async registerStepFailure(
|
||||
workflowId: string,
|
||||
idempotencyKey: string,
|
||||
error?: Error | any
|
||||
): Promise<DistributedTransaction> {
|
||||
if (!WorkflowManager.workflows.has(workflowId)) {
|
||||
throw new Error(`Workflow with id "${workflowId}" not found.`)
|
||||
}
|
||||
|
||||
const workflow = WorkflowManager.workflows.get(workflowId)!
|
||||
return await workflow.orchestrator.registerStepFailure(
|
||||
idempotencyKey,
|
||||
error,
|
||||
workflow.handler(this.container, this.context)
|
||||
)
|
||||
}
|
||||
}
|
||||
3
packages/orchestration/src/workflow/index.ts
Normal file
3
packages/orchestration/src/workflow/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./workflow-manager"
|
||||
export * from "./local-workflow"
|
||||
export * from "./global-workflow"
|
||||
209
packages/orchestration/src/workflow/local-workflow.ts
Normal file
209
packages/orchestration/src/workflow/local-workflow.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { Context, LoadedModule, MedusaContainer } from "@medusajs/types"
|
||||
import {
|
||||
DistributedTransaction,
|
||||
TransactionOrchestrator,
|
||||
TransactionStepsDefinition,
|
||||
} from "../transaction"
|
||||
import {
|
||||
WorkflowDefinition,
|
||||
WorkflowManager,
|
||||
WorkflowStepHandler,
|
||||
} from "./workflow-manager"
|
||||
|
||||
import { OrchestratorBuilder } from "../transaction/orchestrator-builder"
|
||||
import { asValue } from "awilix"
|
||||
import { createMedusaContainer } from "@medusajs/utils"
|
||||
|
||||
type StepHandler = {
|
||||
invoke: WorkflowStepHandler
|
||||
compensate?: WorkflowStepHandler
|
||||
}
|
||||
|
||||
export class LocalWorkflow {
|
||||
protected container: MedusaContainer
|
||||
protected workflowId: string
|
||||
protected flow: OrchestratorBuilder
|
||||
protected workflow: WorkflowDefinition
|
||||
protected handlers: Map<string, StepHandler>
|
||||
|
||||
constructor(
|
||||
workflowId: string,
|
||||
modulesLoaded?: LoadedModule[] | MedusaContainer
|
||||
) {
|
||||
const globalWorkflow = WorkflowManager.getWorkflow(workflowId)
|
||||
if (!globalWorkflow) {
|
||||
throw new Error(`Workflow with id "${workflowId}" not found.`)
|
||||
}
|
||||
|
||||
this.flow = new OrchestratorBuilder(globalWorkflow.flow_)
|
||||
this.workflowId = workflowId
|
||||
this.workflow = globalWorkflow
|
||||
this.handlers = new Map(globalWorkflow.handlers_)
|
||||
|
||||
const container = createMedusaContainer()
|
||||
|
||||
// Medusa container
|
||||
if (!Array.isArray(modulesLoaded) && modulesLoaded) {
|
||||
const cradle = modulesLoaded.cradle
|
||||
for (const key in cradle) {
|
||||
container.register(key, asValue(cradle[key]))
|
||||
}
|
||||
}
|
||||
// Array of modules
|
||||
else if (modulesLoaded?.length) {
|
||||
for (const mod of modulesLoaded) {
|
||||
const registrationName = mod.__definition.registrationName
|
||||
container.register(registrationName, asValue(mod))
|
||||
}
|
||||
}
|
||||
|
||||
this.container = container
|
||||
}
|
||||
|
||||
protected commit() {
|
||||
const finalFlow = this.flow.build()
|
||||
|
||||
this.workflow = {
|
||||
id: this.workflowId,
|
||||
flow_: finalFlow,
|
||||
orchestrator: new TransactionOrchestrator(this.workflowId, finalFlow),
|
||||
handler: WorkflowManager.buildHandlers(this.handlers),
|
||||
handlers_: this.handlers,
|
||||
}
|
||||
}
|
||||
|
||||
async run(uniqueTransactionId: string, input?: unknown, context?: Context) {
|
||||
if (this.flow.hasChanges) {
|
||||
this.commit()
|
||||
}
|
||||
|
||||
const { handler, orchestrator } = this.workflow
|
||||
|
||||
const transaction = await orchestrator.beginTransaction(
|
||||
uniqueTransactionId,
|
||||
handler(this.container, context),
|
||||
input
|
||||
)
|
||||
|
||||
await orchestrator.resume(transaction)
|
||||
|
||||
return transaction
|
||||
}
|
||||
|
||||
async registerStepSuccess(
|
||||
idempotencyKey: string,
|
||||
response?: unknown,
|
||||
context?: Context
|
||||
): Promise<DistributedTransaction> {
|
||||
const { handler, orchestrator } = this.workflow
|
||||
return await orchestrator.registerStepSuccess(
|
||||
idempotencyKey,
|
||||
handler(this.container, context),
|
||||
undefined,
|
||||
response
|
||||
)
|
||||
}
|
||||
|
||||
async registerStepFailure(
|
||||
idempotencyKey: string,
|
||||
error?: Error | any,
|
||||
context?: Context
|
||||
): Promise<DistributedTransaction> {
|
||||
const { handler, orchestrator } = this.workflow
|
||||
return await orchestrator.registerStepFailure(
|
||||
idempotencyKey,
|
||||
error,
|
||||
handler(this.container, context)
|
||||
)
|
||||
}
|
||||
|
||||
addAction(
|
||||
action: string,
|
||||
handler: StepHandler,
|
||||
options: Partial<TransactionStepsDefinition> = {}
|
||||
) {
|
||||
this.assertHandler(handler, action)
|
||||
this.handlers.set(action, handler)
|
||||
|
||||
return this.flow.addAction(action, options)
|
||||
}
|
||||
|
||||
replaceAction(
|
||||
existingAction: string,
|
||||
action: string,
|
||||
handler: StepHandler,
|
||||
options: Partial<TransactionStepsDefinition> = {}
|
||||
) {
|
||||
this.assertHandler(handler, action)
|
||||
this.handlers.set(action, handler)
|
||||
|
||||
return this.flow.replaceAction(existingAction, action, options)
|
||||
}
|
||||
|
||||
insertActionBefore(
|
||||
existingAction: string,
|
||||
action: string,
|
||||
handler: StepHandler,
|
||||
options: Partial<TransactionStepsDefinition> = {}
|
||||
) {
|
||||
this.assertHandler(handler, action)
|
||||
this.handlers.set(action, handler)
|
||||
|
||||
return this.flow.insertActionBefore(existingAction, action, options)
|
||||
}
|
||||
|
||||
insertActionAfter(
|
||||
existingAction: string,
|
||||
action: string,
|
||||
handler: StepHandler,
|
||||
options: Partial<TransactionStepsDefinition> = {}
|
||||
) {
|
||||
this.assertHandler(handler, action)
|
||||
this.handlers.set(action, handler)
|
||||
|
||||
return this.flow.insertActionAfter(existingAction, action, options)
|
||||
}
|
||||
|
||||
appendAction(
|
||||
action: string,
|
||||
to: string,
|
||||
handler: StepHandler,
|
||||
options: Partial<TransactionStepsDefinition> = {}
|
||||
) {
|
||||
this.assertHandler(handler, action)
|
||||
this.handlers.set(action, handler)
|
||||
|
||||
return this.flow.appendAction(action, to, options)
|
||||
}
|
||||
|
||||
moveAction(actionToMove: string, targetAction: string): OrchestratorBuilder {
|
||||
return this.flow.moveAction(actionToMove, targetAction)
|
||||
}
|
||||
|
||||
moveAndMergeNextAction(
|
||||
actionToMove: string,
|
||||
targetAction: string
|
||||
): OrchestratorBuilder {
|
||||
return this.flow.moveAndMergeNextAction(actionToMove, targetAction)
|
||||
}
|
||||
|
||||
mergeActions(where: string, ...actions: string[]) {
|
||||
return this.flow.mergeActions(where, ...actions)
|
||||
}
|
||||
|
||||
deleteAction(action: string, parentSteps?) {
|
||||
return this.flow.deleteAction(action, parentSteps)
|
||||
}
|
||||
|
||||
pruneAction(action: string) {
|
||||
return this.flow.pruneAction(action)
|
||||
}
|
||||
|
||||
protected assertHandler(handler: StepHandler, action: string): void | never {
|
||||
if (!handler?.invoke) {
|
||||
throw new Error(
|
||||
`Handler for action "${action}" is missing invoke function.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
168
packages/orchestration/src/workflow/workflow-manager.ts
Normal file
168
packages/orchestration/src/workflow/workflow-manager.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Context, MedusaContainer } from "@medusajs/types"
|
||||
import {
|
||||
OrchestratorBuilder,
|
||||
TransactionHandlerType,
|
||||
TransactionMetadata,
|
||||
TransactionOrchestrator,
|
||||
TransactionStepHandler,
|
||||
TransactionStepsDefinition,
|
||||
} from "../transaction"
|
||||
|
||||
export interface WorkflowDefinition {
|
||||
id: string
|
||||
handler: (
|
||||
container: MedusaContainer,
|
||||
context?: Context
|
||||
) => TransactionStepHandler
|
||||
orchestrator: TransactionOrchestrator
|
||||
flow_: TransactionStepsDefinition
|
||||
handlers_: Map<
|
||||
string,
|
||||
{ invoke: WorkflowStepHandler; compensate?: WorkflowStepHandler }
|
||||
>
|
||||
requiredModules?: Set<string>
|
||||
optionalModules?: Set<string>
|
||||
}
|
||||
|
||||
export type WorkflowHandler = Map<
|
||||
string,
|
||||
{ invoke: WorkflowStepHandler; compensate?: WorkflowStepHandler }
|
||||
>
|
||||
|
||||
export type WorkflowStepHandler = (args: {
|
||||
container: MedusaContainer
|
||||
payload: unknown
|
||||
invoke: { [actions: string]: unknown }
|
||||
compensate: { [actions: string]: unknown }
|
||||
metadata: TransactionMetadata
|
||||
context?: Context
|
||||
}) => unknown
|
||||
|
||||
export class WorkflowManager {
|
||||
protected static workflows: Map<string, WorkflowDefinition> = new Map()
|
||||
|
||||
static unregister(workflowId: string) {
|
||||
WorkflowManager.workflows.delete(workflowId)
|
||||
}
|
||||
|
||||
static unregisterAll() {
|
||||
WorkflowManager.workflows.clear()
|
||||
}
|
||||
|
||||
static getWorkflows() {
|
||||
return WorkflowManager.workflows
|
||||
}
|
||||
|
||||
static getWorkflow(workflowId: string) {
|
||||
return WorkflowManager.workflows.get(workflowId)
|
||||
}
|
||||
|
||||
static getTransactionDefinition(workflowId): OrchestratorBuilder {
|
||||
if (!WorkflowManager.workflows.has(workflowId)) {
|
||||
throw new Error(`Workflow with id "${workflowId}" not found.`)
|
||||
}
|
||||
|
||||
const workflow = WorkflowManager.workflows.get(workflowId)!
|
||||
return new OrchestratorBuilder(workflow.flow_)
|
||||
}
|
||||
|
||||
static register(
|
||||
workflowId: string,
|
||||
flow: TransactionStepsDefinition | OrchestratorBuilder,
|
||||
handlers: WorkflowHandler,
|
||||
requiredModules?: Set<string>,
|
||||
optionalModules?: Set<string>
|
||||
) {
|
||||
if (WorkflowManager.workflows.has(workflowId)) {
|
||||
throw new Error(`Workflow with id "${workflowId}" is already defined.`)
|
||||
}
|
||||
|
||||
const finalFlow = flow instanceof OrchestratorBuilder ? flow.build() : flow
|
||||
|
||||
WorkflowManager.workflows.set(workflowId, {
|
||||
id: workflowId,
|
||||
flow_: finalFlow,
|
||||
orchestrator: new TransactionOrchestrator(workflowId, finalFlow),
|
||||
handler: WorkflowManager.buildHandlers(handlers),
|
||||
handlers_: handlers,
|
||||
requiredModules,
|
||||
optionalModules,
|
||||
})
|
||||
}
|
||||
|
||||
static update(
|
||||
workflowId: string,
|
||||
flow: TransactionStepsDefinition | OrchestratorBuilder,
|
||||
handlers: Map<
|
||||
string,
|
||||
{ invoke: WorkflowStepHandler; compensate?: WorkflowStepHandler }
|
||||
>,
|
||||
requiredModules?: Set<string>,
|
||||
optionalModules?: Set<string>
|
||||
) {
|
||||
if (!WorkflowManager.workflows.has(workflowId)) {
|
||||
throw new Error(`Workflow with id "${workflowId}" not found.`)
|
||||
}
|
||||
|
||||
const workflow = WorkflowManager.workflows.get(workflowId)!
|
||||
|
||||
for (const [key, value] of handlers.entries()) {
|
||||
workflow.handlers_.set(key, value)
|
||||
}
|
||||
|
||||
const finalFlow = flow instanceof OrchestratorBuilder ? flow.build() : flow
|
||||
|
||||
WorkflowManager.workflows.set(workflowId, {
|
||||
id: workflowId,
|
||||
flow_: finalFlow,
|
||||
orchestrator: new TransactionOrchestrator(workflowId, finalFlow),
|
||||
handler: WorkflowManager.buildHandlers(workflow.handlers_),
|
||||
handlers_: workflow.handlers_,
|
||||
requiredModules,
|
||||
optionalModules,
|
||||
})
|
||||
}
|
||||
|
||||
public static buildHandlers(
|
||||
handlers: Map<
|
||||
string,
|
||||
{ invoke: WorkflowStepHandler; compensate?: WorkflowStepHandler }
|
||||
>
|
||||
): (container: MedusaContainer, context?: Context) => TransactionStepHandler {
|
||||
return (
|
||||
container: MedusaContainer,
|
||||
context?: Context
|
||||
): TransactionStepHandler => {
|
||||
return async (
|
||||
actionId: string,
|
||||
handlerType: TransactionHandlerType,
|
||||
payload?: any
|
||||
) => {
|
||||
const command = handlers.get(actionId)
|
||||
|
||||
if (!command) {
|
||||
throw new Error(`Handler for action "${actionId}" not found.`)
|
||||
} else if (!command[handlerType]) {
|
||||
throw new Error(
|
||||
`"${handlerType}" handler for action "${actionId}" not found.`
|
||||
)
|
||||
}
|
||||
|
||||
const { invoke, compensate, payload: input } = payload.context
|
||||
const { metadata } = payload
|
||||
|
||||
return await command[handlerType]!({
|
||||
container,
|
||||
payload: input,
|
||||
invoke,
|
||||
compensate,
|
||||
metadata,
|
||||
context,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
global.WorkflowManager ??= WorkflowManager
|
||||
exports.WorkflowManager = global.WorkflowManager
|
||||
Reference in New Issue
Block a user